UNPKG

@uplink-protocol/calendar-controller

Version:

Flexible calendar API supporting both calendar and date-picker integrations for any JavaScript framework or library

1,325 lines (1,109 loc) 55.6 kB
/** * Advanced Floating Calendar Widget * Complete overhaul with modern features and enhanced UX */ class AdvancedFloatingCalendar { constructor(options = {}) { this.options = { container: '#floating-calendar-widget', initialDate: new Date(), features: { draggable: true, minimizable: true, quickActions: true, animations: true, themes: ['default', 'dark', 'gradient'], rangeSelection: true }, position: { top: 20, right: 20 }, responsive: true, ...options }; this.state = { currentDate: new Date(this.options.initialDate), selectedDate: null, selectedRange: { start: null, end: null }, isMinimized: false, currentTheme: 'default', isDragging: false, position: this.options.position, rangeMode: false, animations: true }; this.eventListeners = new Map(); this.widget = null; this.dragState = { isDragging: false, offset: { x: 0, y: 0 } }; this.resizeTimeout = null; // For debouncing resize events // Bound methods for event listeners (for proper cleanup) this.boundHandleResize = this.handleResize.bind(this); this.boundHandleKeydown = this.handleKeydown.bind(this); this.boundHandleMouseMove = this.handleMouseMove.bind(this); this.boundHandleMouseUp = this.handleMouseUp.bind(this); // Event listener tracking for cleanup this.documentListeners = new Set(); this.windowListeners = new Set(); // Calendar controller for integration this.calendarController = null; this.calendarSubscriptions = []; this.init(); } init() { this.widget = document.querySelector(this.options.container); if (!this.widget) { console.error('Calendar widget container not found'); return; } // Initialize CalendarController integration this.initCalendarController(); // Start performance monitoring this.startPerformanceMonitor(); this.setupEventListeners(); this.render(); this.updatePosition(); this.loadSavedState(); // Initialize with animation setTimeout(() => { this.widget.classList.add('initialized'); }, 100); // Smart positioning on load this.smartPosition(); console.log('Advanced Floating Calendar initialized'); this.showNotification('Advanced Floating Calendar Ready!', 'success'); } setupEventListeners() { // Clean up any existing listeners first this.removeEventListeners(); // Minimize/Maximize toggle - use onclick attribute selector instead of CSS class const minimizeBtn = this.widget.querySelector('button[onclick*="toggleMinimize"]'); if (minimizeBtn) { minimizeBtn.addEventListener('click', () => this.toggleMinimize()); } // Date cell clicks - using event delegation for better performance this.widget.addEventListener('click', (e) => { if (e.target.hasAttribute('data-date')) { this.handleDateClick(e.target); } }); // Navigation buttons - check if they exist in the floating calendar structure const prevBtn = this.widget.querySelector('button[onclick*="goToPrevious"]'); const nextBtn = this.widget.querySelector('button[onclick*="goToNext"]'); if (prevBtn) prevBtn.addEventListener('click', () => this.goToPreviousMonth()); if (nextBtn) nextBtn.addEventListener('click', () => this.goToNextMonth()); // Quick action buttons this.setupQuickActions(); // Drag functionality this.setupDragFunctionality(); // Keyboard navigation - only add once if (!this.documentListeners.has('keydown')) { document.addEventListener('keydown', this.boundHandleKeydown); this.documentListeners.add('keydown'); } // Window resize handler - only add once if (!this.windowListeners.has('resize')) { window.addEventListener('resize', this.boundHandleResize); this.windowListeners.add('resize'); } }setupQuickActions() { // Quick action buttons are already set up via onclick attributes in HTML // They call methods like window.advancedCalendar.selectToday() directly // No additional event listeners needed since they use onclick attributes console.log('Quick actions are handled via onclick attributes in HTML'); } setupDragFunctionality() { // Use the actual header class that exists in the HTML const header = this.widget.querySelector('.bg-gradient-to-r'); if (!header) { console.warn('Calendar header not found for drag functionality'); return; } header.addEventListener('mousedown', (e) => { // Don't start drag if clicking on buttons if (e.target.closest('button')) return; this.dragState.isDragging = true; this.dragState.offset = { x: e.clientX - this.widget.offsetLeft, y: e.clientY - this.widget.offsetTop }; this.widget.classList.add('dragging'); document.body.style.userSelect = 'none'; }); // Add mouse move and up listeners only once if (!this.documentListeners.has('mousemove')) { document.addEventListener('mousemove', this.boundHandleMouseMove); this.documentListeners.add('mousemove'); } if (!this.documentListeners.has('mouseup')) { document.addEventListener('mouseup', this.boundHandleMouseUp); this.documentListeners.add('mouseup'); } } handleMouseMove(e) { if (!this.dragState.isDragging) return; const newX = e.clientX - this.dragState.offset.x; const newY = e.clientY - this.dragState.offset.y; // Constrain to viewport const rect = this.widget.getBoundingClientRect(); const maxX = window.innerWidth - rect.width; const maxY = window.innerHeight - rect.height; const constrainedX = Math.max(0, Math.min(newX, maxX)); const constrainedY = Math.max(0, Math.min(newY, maxY)); this.updatePosition({ top: constrainedY, left: constrainedX, right: 'auto' }); } handleMouseUp() { if (this.dragState.isDragging) { this.dragState.isDragging = false; this.widget.classList.remove('dragging'); document.body.style.userSelect = ''; this.savePosition(); this.emit('dragEnd', this.state.position); } } handleKeydown(e) { // Only handle keyboard events when the calendar is focused or has focus within if (!this.widget.contains(document.activeElement) && document.activeElement !== this.widget) { return; } switch (e.key) { case 'ArrowLeft': e.preventDefault(); this.navigateDate(-1); break; case 'ArrowRight': e.preventDefault(); this.navigateDate(1); break; case 'ArrowUp': e.preventDefault(); this.navigateDate(-7); break; case 'ArrowDown': e.preventDefault(); this.navigateDate(7); break; case 'Enter': case ' ': e.preventDefault(); this.selectFocusedDate(); break; case 'Escape': e.preventDefault(); this.clearSelection(); break; } } setupKeyboardNavigation() { // This method is now handled in setupEventListeners() to prevent duplicate listeners // Keyboard navigation is set up once using bound methods for proper cleanup console.log('Keyboard navigation handled via setupEventListeners()'); }render() { const startTime = performance.now(); this.renderCalendar(); this.renderWeekdayHeaders(); this.updateMiniView(); this.updateStatusDisplay(); // Record performance data this.recordRenderTime(startTime); } renderWeekdayHeaders() { const container = this.widget.querySelector('#floatingWeekdayHeaders'); if (!container) return; const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; container.innerHTML = weekdays.map(day => `<div class="floating-weekday-header">${day}</div>` ).join(''); } renderCalendar() { const container = this.widget.querySelector('#floatingCalendarDays'); if (!container) return; const year = this.state.currentDate.getFullYear(); const month = this.state.currentDate.getMonth(); // Update month/year display const monthYearDisplay = this.widget.querySelector('#floatingMonthYear'); if (monthYearDisplay) { monthYearDisplay.textContent = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(this.state.currentDate); } // Generate calendar days const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const firstDayOfWeek = firstDay.getDay(); const daysInMonth = lastDay.getDate(); const days = []; // Previous month's days const prevMonth = new Date(year, month - 1, 0); for (let i = firstDayOfWeek - 1; i >= 0; i--) { const date = new Date(year, month - 1, prevMonth.getDate() - i); days.push(this.createDateCell(date, true)); } // Current month's days for (let day = 1; day <= daysInMonth; day++) { const date = new Date(year, month, day); days.push(this.createDateCell(date, false)); } // Next month's days const totalCells = Math.ceil(days.length / 7) * 7; let nextMonthDay = 1; for (let i = days.length; i < totalCells; i++) { const date = new Date(year, month + 1, nextMonthDay); days.push(this.createDateCell(date, true)); nextMonthDay++; } container.innerHTML = days.join(''); this.attachDateCellListeners(); } createDateCell(date, isOtherMonth) { const today = new Date(); const isToday = this.isSameDate(date, today); const isSelected = this.isDateSelected(date); const isInRange = this.isDateInRange(date); // Base Tailwind classes for floating calendar date cells const baseClasses = [ 'w-8', 'h-8', 'flex', 'items-center', 'justify-center', 'text-sm', 'font-medium', 'rounded-lg', 'cursor-pointer', 'transition-all', 'duration-200', 'hover:bg-blue-100', 'dark:hover:bg-blue-900/50', 'hover:scale-105', 'select-none' ]; // Conditional classes based on date state if (isOtherMonth) { baseClasses.push('text-gray-400', 'dark:text-gray-500'); } else { baseClasses.push('text-gray-900', 'dark:text-gray-100'); } if (isToday) { baseClasses.push('ring-2', 'ring-blue-500', 'ring-inset', 'font-bold', 'text-blue-600', 'dark:text-blue-300'); } if (isSelected) { baseClasses.push('bg-blue-600', 'text-white', 'shadow-lg', 'hover:bg-blue-700', 'dark:hover:bg-blue-500'); } if (isInRange) { baseClasses.push('bg-blue-100', 'dark:bg-blue-900/30', 'text-blue-700', 'dark:text-blue-200'); } return ` <div class="${baseClasses.join(' ')}" data-date="${date.toISOString()}" title="${this.formatDateForTitle(date)}" tabindex="0" role="button" aria-label="${this.formatDateForTitle(date)}"> ${date.getDate()} </div> `; } attachDateCellListeners() { // Use event delegation instead of individual listeners // Event delegation is already handled in setupEventListeners() // This method now just ensures proper tabindex and aria attributes const dateCells = this.widget.querySelectorAll('[data-date]'); dateCells.forEach(cell => { // Set up accessibility attributes but no individual event listeners cell.setAttribute('tabindex', '0'); cell.setAttribute('role', 'button'); // Add hover and focus handlers directly as these don't accumulate cell.addEventListener('mouseenter', () => this.handleDateHover(cell)); cell.addEventListener('mouseleave', () => this.handleDateLeave(cell)); cell.addEventListener('focus', () => this.handleDateFocus(cell)); cell.addEventListener('keydown', (e) => this.handleDateKeydown(e, cell)); }); } handleDateLeave(cell) { // Remove hover effects when mouse leaves cell.classList.remove('bg-gray-100', 'dark:bg-gray-700'); }handleDateClick(cell) { const dateStr = cell.getAttribute('data-date'); const date = new Date(dateStr); if (this.state.rangeMode) { this.handleRangeSelection(date); } else { // Use CalendarController for date selection if available this.syncToController('selectDate', date); // Fallback to internal logic if controller not available if (!this.calendarController) { this.selectDate(date); } } this.render(); this.updateStatusDisplay(); this.emit('dateSelect', date); } handleDateHover(cell) { const dateStr = cell.getAttribute('data-date'); const date = new Date(dateStr); // Show preview for range selection if (this.state.rangeMode && this.state.selectedRange.start && !this.state.selectedRange.end) { this.previewRange(this.state.selectedRange.start, date); } // Add hover effect if (!cell.classList.contains('bg-blue-600')) { cell.classList.add('bg-gray-100', 'dark:bg-gray-700'); } } handleDateFocus(cell) { // Remove focus from other cells const focusedCells = this.widget.querySelectorAll('[data-date].ring-2.ring-yellow-400'); focusedCells.forEach(c => { c.classList.remove('ring-2', 'ring-yellow-400'); }); // Add focus ring to current cell cell.classList.add('ring-2', 'ring-yellow-400'); } handleDateKeydown(e, cell) { const dateStr = cell.getAttribute('data-date'); const date = new Date(dateStr); switch (e.key) { case 'Enter': case ' ': e.preventDefault(); this.handleDateClick(cell); break; case 'ArrowLeft': e.preventDefault(); this.focusAdjacentDate(date, -1); break; case 'ArrowRight': e.preventDefault(); this.focusAdjacentDate(date, 1); break; case 'ArrowUp': e.preventDefault(); this.focusAdjacentDate(date, -7); break; case 'ArrowDown': e.preventDefault(); this.focusAdjacentDate(date, 7); break; } } focusAdjacentDate(currentDate, dayOffset) { const targetDate = new Date(currentDate); targetDate.setDate(currentDate.getDate() + dayOffset); const targetCell = this.widget.querySelector(`[data-date="${targetDate.toISOString()}"]`); if (targetCell) { targetCell.focus(); } }handleRangeSelection(date) { if (!this.state.selectedRange.start || this.state.selectedRange.end) { // Start new range this.state.selectedRange = { start: date, end: null }; // Sync with controller for range start this.syncToController('selectDate', date); } else { // Complete range const start = this.state.selectedRange.start; this.state.selectedRange.end = date < start ? start : date; this.state.selectedRange.start = date < start ? date : start; // Sync with controller for range end this.syncToController('selectDate', this.state.selectedRange.end); this.emit('rangeSelect', this.state.selectedRange); } } previewRange(start, end) { const cells = this.widget.querySelectorAll('[data-date]'); cells.forEach(cell => { // Remove existing preview classes cell.classList.remove('bg-blue-200', 'dark:bg-blue-800/50'); const cellDate = new Date(cell.getAttribute('data-date')); if (cellDate >= start && cellDate <= end) { // Add preview styling for range if (!cell.classList.contains('bg-blue-600')) { cell.classList.add('bg-blue-200', 'dark:bg-blue-800/50'); } } }); }// Quick Action Methods selectToday() { const today = new Date(); // Use CalendarController for date selection if available this.syncToController('selectDate', today); // Fallback to internal logic if controller not available if (!this.calendarController) { this.selectDate(today); } this.updateStatusDisplay('Today selected'); this.animateAction('today'); } selectTomorrow() { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); // Use CalendarController for date selection if available this.syncToController('selectDate', tomorrow); // Fallback to internal logic if controller not available if (!this.calendarController) { this.selectDate(tomorrow); } this.updateStatusDisplay('Tomorrow selected'); this.animateAction('tomorrow'); } selectWeekend() { const today = new Date(); const saturday = new Date(today); const sunday = new Date(today); const daysUntilSaturday = (6 - today.getDay()) % 7; const daysUntilSunday = (7 - today.getDay()) % 7; saturday.setDate(today.getDate() + daysUntilSaturday); sunday.setDate(today.getDate() + daysUntilSunday); // Enable range mode and sync with controller this.enableRangeMode(); this.syncToController('setRangeMode', true); this.state.selectedRange = { start: saturday, end: sunday }; // Sync range selection with controller this.syncToController('selectDate', saturday); this.syncToController('selectDate', sunday); this.render(); this.updateStatusDisplay('Weekend selected'); this.animateAction('weekend'); } selectNextWeek() { const today = new Date(); const nextWeekStart = new Date(today); const nextWeekEnd = new Date(today); nextWeekStart.setDate(today.getDate() + 7); nextWeekEnd.setDate(today.getDate() + 13); // Enable range mode and sync with controller this.enableRangeMode(); this.syncToController('setRangeMode', true); this.state.selectedRange = { start: nextWeekStart, end: nextWeekEnd }; // Sync range selection with controller this.syncToController('selectDate', nextWeekStart); this.syncToController('selectDate', nextWeekEnd); this.render(); this.updateStatusDisplay('Next week selected'); this.animateAction('next-week'); } clearAll() { // Clear selection using controller if available this.syncToController('clearSelection'); // Fallback to internal logic if controller not available if (!this.calendarController) { this.state.selectedDate = null; this.state.selectedRange = { start: null, end: null }; } this.render(); this.updateStatusDisplay('Selection cleared'); this.animateAction('clear'); } showTooltips() { this.showTemporaryMessage('Keyboard: ←→↑↓ navigate, Enter/Space select, Esc clear'); } // Theme and Appearance Methods toggleTheme() { const themes = this.options.features.themes; const currentIndex = themes.indexOf(this.state.currentTheme); const nextIndex = (currentIndex + 1) % themes.length; this.state.currentTheme = themes[nextIndex]; // Remove all theme classes themes.forEach(theme => { this.widget.classList.remove(`${theme}-theme`); }); // Add new theme class if (this.state.currentTheme !== 'default') { this.widget.classList.add(`${this.state.currentTheme}-theme`); } this.updateThemeDisplay(); this.saveState(); this.emit('themeChange', this.state.currentTheme); } toggleMinimize() { this.state.isMinimized = !this.state.isMinimized; this.widget.classList.toggle('minimized', this.state.isMinimized); const icon = this.widget.querySelector('#minimizeIcon'); if (icon) { icon.className = this.state.isMinimized ? 'fas fa-window-maximize' : 'fas fa-window-minimize'; } this.updateMiniView(); this.saveState(); this.emit(this.state.isMinimized ? 'minimize' : 'maximize'); } enableRangeMode() { // Sync with controller first this.syncToController('setRangeMode', true); // Fallback to internal logic if controller not available if (!this.calendarController) { this.state.rangeMode = true; } this.updateModeDisplay(); this.showTemporaryMessage('Range selection enabled'); } disableRangeMode() { // Sync with controller first this.syncToController('setRangeMode', false); // Fallback to internal logic if controller not available if (!this.calendarController) { this.state.rangeMode = false; this.state.selectedRange = { start: null, end: null }; } this.updateModeDisplay(); this.showTemporaryMessage('Single selection enabled'); } // Animation and Effects showAnimationDemo() { const cells = this.widget.querySelectorAll('[data-date]'); cells.forEach((cell, index) => { setTimeout(() => { cell.style.transform = 'scale(1.2) rotate(360deg)'; cell.style.transition = 'transform 0.6s ease'; setTimeout(() => { cell.style.transform = ''; }, 600); }, index * 50); }); this.showTemporaryMessage('Animation demo complete!'); } animateAction(action) { this.widget.classList.add(`action-${action}`); setTimeout(() => { this.widget.classList.remove(`action-${action}`); }, 600); } stressTest() { this.startPerformanceMonitor(); const startTime = performance.now(); let iterations = 0; const test = () => { this.render(); iterations++; if (iterations < 100) { requestAnimationFrame(test); } else { const endTime = performance.now(); const duration = endTime - startTime; const report = this.getPerformanceReport(); this.showNotification( `Stress test complete: ${iterations} renders in ${duration.toFixed(2)}ms | Performance Score: ${report.performanceScore}`, 'info' ); console.log('Performance Report:', report); } }; test(); } // Utility Methods selectDate(date) { this.state.selectedDate = new Date(date); this.goToDate(date); } // Navigation Methods - Integrated with CalendarController goToPreviousMonth() { // Sync with controller first this.syncToController('goToPreviousMonth'); // Fallback to internal logic if controller not available if (!this.calendarController) { const newDate = new Date(this.state.currentDate); newDate.setMonth(newDate.getMonth() - 1); this.state.currentDate = newDate; this.render(); } this.emit('monthChange', { month: this.state.currentDate.getMonth(), year: this.state.currentDate.getFullYear() }); } goToNextMonth() { // Sync with controller first this.syncToController('goToNextMonth'); // Fallback to internal logic if controller not available if (!this.calendarController) { const newDate = new Date(this.state.currentDate); newDate.setMonth(newDate.getMonth() + 1); this.state.currentDate = newDate; this.render(); } this.emit('monthChange', { month: this.state.currentDate.getMonth(), year: this.state.currentDate.getFullYear() }); } goToDate(date) { // Sync with controller first this.syncToController('goToMonth', date.getMonth(), date.getFullYear()); // Fallback to internal logic if controller not available if (!this.calendarController) { this.state.currentDate = new Date(date); this.render(); } this.emit('dateNavigation', date); } isDateSelected(date) { return this.state.selectedDate && this.isSameDate(date, this.state.selectedDate); } isDateInRange(date) { const { start, end } = this.state.selectedRange; return start && end && date >= start && date <= end; } isSameDate(date1, date2) { return date1.toDateString() === date2.toDateString(); } formatDateForTitle(date) { return new Intl.DateTimeFormat('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).format(date); } // UI Update Methods updateMiniView() { const miniDate = this.widget.querySelector('#miniDate'); const miniMonth = this.widget.querySelector('#miniMonth'); if (miniDate && miniMonth) { const today = new Date(); miniDate.textContent = today.getDate(); miniMonth.textContent = today.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); } } updateStatusDisplay(message = null) { const statusEl = this.widget.querySelector('#floatingStatus'); if (!statusEl) return; if (message) { statusEl.textContent = message; return; } let status = 'Ready for interaction...'; if (this.state.selectedDate) { status = `Selected: ${this.state.selectedDate.toLocaleDateString()}`; } else if (this.state.selectedRange.start && this.state.selectedRange.end) { const days = Math.ceil((this.state.selectedRange.end - this.state.selectedRange.start) / (1000 * 60 * 60 * 24)) + 1; status = `Range: ${days} days selected`; } statusEl.textContent = status; } updateThemeDisplay() { const themeEl = document.querySelector('#widgetTheme'); if (themeEl) { themeEl.textContent = this.state.currentTheme.charAt(0).toUpperCase() + this.state.currentTheme.slice(1); } } updateModeDisplay() { const modeEl = document.querySelector('#widgetMode'); if (modeEl) { modeEl.textContent = this.state.rangeMode ? 'Range Selection' : 'Single Selection'; } } updatePosition(position = null) { if (position) { Object.assign(this.state.position, position); } const { top, left, right } = this.state.position; this.widget.style.top = typeof top === 'number' ? `${top}px` : top; this.widget.style.right = typeof right === 'number' ? `${right}px` : right; if (left !== undefined) { this.widget.style.left = typeof left === 'number' ? `${left}px` : left; } } showTemporaryMessage(message, duration = 3000) { const originalStatus = this.widget.querySelector('#floatingStatus').textContent; this.updateStatusDisplay(message); setTimeout(() => { this.updateStatusDisplay(originalStatus); }, duration); } // Enhanced floating calendar notification system showNotification(message, type = 'success') { // Remove existing notification const existingNotification = this.widget.querySelector('.floating-notification'); if (existingNotification) { existingNotification.remove(); } // Create new notification const notification = document.createElement('div'); notification.className = `floating-notification ${type}`; notification.textContent = message; this.widget.appendChild(notification); // Show notification setTimeout(() => { notification.classList.add('show'); }, 100); // Hide notification after delay setTimeout(() => { notification.classList.remove('show'); setTimeout(() => { if (notification.parentNode) { notification.remove(); } }, 300); }, 3000); } // Enhanced theme management applyTheme(themeName) { const themes = { default: { headerGradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', backgroundColor: 'rgba(255, 255, 255, 0.98)', textColor: '#1f2937' }, dark: { headerGradient: 'linear-gradient(135deg, #1e293b 0%, #334155 100%)', backgroundColor: 'rgba(30, 41, 59, 0.95)', textColor: '#f8fafc' }, gradient: { headerGradient: 'linear-gradient(135deg, #a855f7 0%, #3b82f6 50%, #10b981 100%)', backgroundColor: 'linear-gradient(135deg, rgba(168, 85, 247, 0.95) 0%, rgba(59, 130, 246, 0.95) 50%, rgba(16, 185, 129, 0.95) 100%)', textColor: '#ffffff' } }; const theme = themes[themeName] || themes.default; // Apply theme styles dynamically const header = this.widget.querySelector('.floating-calendar-header'); if (header) { header.style.background = theme.headerGradient; } this.widget.style.background = theme.backgroundColor; this.widget.style.color = theme.textColor; this.showNotification(`${themeName.charAt(0).toUpperCase() + themeName.slice(1)} theme applied`); } // Smart positioning system smartPosition() { const rect = this.widget.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let newPosition = { ...this.state.position }; let needsUpdate = false; // Check if widget is outside viewport bounds if (rect.left < 0) { newPosition.left = 20; newPosition.right = 'auto'; needsUpdate = true; } else if (rect.right > viewportWidth) { // If widget is beyond right edge, move it to the right side with margin newPosition.right = 20; newPosition.left = 'auto'; needsUpdate = true; } // Check vertical positioning if (rect.top < 0) { newPosition.top = 20; needsUpdate = true; } else if (rect.bottom > viewportHeight) { // If widget is below viewport, move it up with margin newPosition.top = Math.max(20, viewportHeight - rect.height - 20); needsUpdate = true; } // For cases where the widget might be too close to edges after resize if (rect.right > viewportWidth - 20 && rect.left > viewportWidth / 2) { newPosition.right = 20; newPosition.left = 'auto'; needsUpdate = true; } if (rect.bottom > viewportHeight - 20 && rect.top > viewportHeight / 2) { newPosition.top = viewportHeight - rect.height - 20; needsUpdate = true; } if (needsUpdate) { this.updatePosition(newPosition); } } // Handle window resize events handleResize() { // Debounce resize events for better performance clearTimeout(this.resizeTimeout); this.resizeTimeout = setTimeout(() => { // Re-run smart positioning to ensure widget stays within viewport this.smartPosition(); // Emit resize event for any listeners this.emit('resize', { viewport: { width: window.innerWidth, height: window.innerHeight }, widget: this.widget.getBoundingClientRect() }); }, 150); // 150ms debounce delay } // Performance monitoring startPerformanceMonitor() { this.performanceData = { renderCount: 0, renderTimes: [], interactionCount: 0, startTime: performance.now() }; return this.performanceData; } recordRenderTime(startTime) { const renderTime = performance.now() - startTime; if (this.performanceData) { this.performanceData.renderCount++; this.performanceData.renderTimes.push(renderTime); } return renderTime; } getPerformanceReport() { if (!this.performanceData) return null; const { renderCount, renderTimes, interactionCount, startTime } = this.performanceData; const totalTime = performance.now() - startTime; const avgRenderTime = renderTimes.reduce((a, b) => a + b, 0) / renderTimes.length; return { totalRenderCount: renderCount, averageRenderTime: avgRenderTime.toFixed(2), totalInteractions: interactionCount, uptime: (totalTime / 1000).toFixed(2), performanceScore: this.calculatePerformanceScore(avgRenderTime, renderCount, totalTime) }; } calculatePerformanceScore(avgRenderTime, renderCount, totalTime) { // Simple performance scoring algorithm let score = 100; if (avgRenderTime > 16) score -= 20; // 60fps threshold if (avgRenderTime > 33) score -= 30; // 30fps threshold if (renderCount < 10) score -= 10; // Not enough data if (totalTime > 60000) score += 10; // Stability bonus return Math.max(0, Math.min(100, score)); } // State Management saveState() { const state = { isMinimized: this.state.isMinimized, currentTheme: this.state.currentTheme, position: this.state.position, rangeMode: this.state.rangeMode }; localStorage.setItem('advancedCalendarState', JSON.stringify(state)); } savePosition() { const position = { top: parseInt(this.widget.style.top), left: parseInt(this.widget.style.left), right: this.widget.style.right === 'auto' ? 'auto' : parseInt(this.widget.style.right) }; localStorage.setItem('advancedCalendarPosition', JSON.stringify(position)); } loadSavedState() { const savedState = localStorage.getItem('advancedCalendarState'); const savedPosition = localStorage.getItem('advancedCalendarPosition'); if (savedState) { const state = JSON.parse(savedState); Object.assign(this.state, state); if (this.state.isMinimized) { this.toggleMinimize(); } if (this.state.currentTheme !== 'default') { this.widget.classList.add(`${this.state.currentTheme}-theme`); } } if (savedPosition) { this.state.position = JSON.parse(savedPosition); this.updatePosition(); } } // Event System on(event, callback) { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, []); } this.eventListeners.get(event).push(callback); } emit(event, data) { if (this.eventListeners.has(event)) { this.eventListeners.get(event).forEach(callback => callback(data)); } } // CalendarController Integration initCalendarController() { try { // Use the shared calendar controller instance from the comprehensive demo this.calendarController = window.sharedCalendarController; if (!this.calendarController) { console.warn('Shared calendar controller not found, creating new instance...'); // Fallback: create new instance if shared not available this.calendarController = window.CalendarController({ initialSelectedDate: this.state.currentDate, firstDayOfWeek: 0, // Sunday first locale: 'en-US', isRangeSelection: this.state.rangeMode }); } // Subscribe to calendar controller events this.setupCalendarBindings(); // Sync initial state this.syncFromController(); console.log('Calendar controller integrated with floating calendar'); } catch (error) { console.warn('Calendar controller not available, using internal calendar logic:', error); } } setupCalendarBindings() { if (!this.calendarController) return; // Subscribe to selected date changes this.calendarController.bindings.selectedDate.subscribe((date) => { if (date && !this.isSameDate(date, this.state.selectedDate)) { this.state.selectedDate = date; this.updateStatusDisplay(); this.render(); this.emit('dateSelect', date); } }); // Subscribe to current date (navigation) changes this.calendarController.bindings.currentDate.subscribe((date) => { if (date && !this.isSameDate(date, this.state.currentDate)) { this.state.currentDate = new Date(date); this.render(); this.emit('monthChange', { month: date.getMonth(), year: date.getFullYear() }); } }); // Subscribe to calendar days updates this.calendarController.bindings.calendarDays.subscribe((days) => { // Update our internal calendar rendering with controller data this.updateCalendarWithControllerData(days); }); // Subscribe to range selection changes if available if (this.calendarController.bindings.selectedRange) { this.calendarController.bindings.selectedRange.subscribe((range) => { if (range) { this.state.selectedRange = { start: range.start, end: range.end }; this.updateStatusDisplay(); this.render(); this.emit('rangeSelect', range); } }); } } syncFromController() { if (!this.calendarController) return; // Sync current state from controller const controllerDate = this.calendarController.bindings.currentDate.current; if (controllerDate) { this.state.currentDate = new Date(controllerDate); } const selectedDate = this.calendarController.bindings.selectedDate.current; if (selectedDate) { this.state.selectedDate = new Date(selectedDate); } // Generate calendar days using controller if (this.calendarController.methods && this.calendarController.methods.generateCalendarDays) { this.updateCalendarWithControllerData(this.calendarController.methods.generateCalendarDays()); } } syncToController(action, ...args) { if (!this.calendarController || !this.calendarController.methods) return; try { switch (action) { case 'selectDate': if (this.calendarController.methods.selectDate) { const [date] = args; this.calendarController.methods.selectDate( date.getFullYear(), date.getMonth(), date.getDate() ); } break; case 'goToMonth': if (this.calendarController.methods.goToMonth) { const [month, year] = args; this.calendarController.methods.goToMonth(month, year); } break; case 'goToNextMonth': if (this.calendarController.methods.goToNextMonth) { this.calendarController.methods.goToNextMonth(); } break; case 'goToPreviousMonth': if (this.calendarController.methods.goToPreviousMonth) { this.calendarController.methods.goToPreviousMonth(); } break; case 'clearSelection': if (this.calendarController.methods.clearSelection) { this.calendarController.methods.clearSelection(); } break; case 'setRangeMode': if (this.calendarController.methods.setRangeSelection) { const [enabled] = args; this.calendarController.methods.setRangeSelection(enabled); } break; } } catch (error) { console.warn('Error syncing to controller:', error); } } updateCalendarWithControllerData(days) { if (!days || !Array.isArray(days)) return; const container = this.widget.querySelector('#floatingCalendarDays'); if (!container) return; // Clear existing content container.innerHTML = ''; // Create date cells from controller data days.forEach(dayData => { const cell = document.createElement('div'); // Use Tailwind classes for consistent styling const baseClasses = [ 'w-8', 'h-8', 'flex', 'items-center', 'justify-center', 'text-sm', 'font-medium', 'rounded-lg', 'cursor-pointer', 'transition-all', 'duration-200', 'hover:bg-blue-100', 'dark:hover:bg-blue-900/50', 'hover:scale-105', 'select-none' ]; // Apply conditional styling based on controller data if (!dayData.isCurrentMonth) { baseClasses.push('text-gray-400', 'dark:text-gray-500'); } else { baseClasses.push('text-gray-900', 'dark:text-gray-100'); } if (dayData.isToday) { baseClasses.push('ring-2', 'ring-blue-500', 'ring-inset', 'font-bold', 'text-blue-600', 'dark:text-blue-300'); } if (dayData.isSelected) { baseClasses.push('bg-blue-600', 'text-white', 'shadow-lg', 'hover:bg-blue-700', 'dark:hover:bg-blue-500'); } if (dayData.isInRange) { baseClasses.push('bg-blue-100', 'dark:bg-blue-900/30', 'text-blue-700', 'dark:text-blue-200'); } if (dayData.isRangeStart || dayData.isRangeEnd) { baseClasses.push('bg-blue-600', 'text-white', 'shadow-lg'); } cell.className = baseClasses.join(' '); cell.textContent = dayData.day || ''; // Store date data for click handling if (dayData.date) { cell.dataset.date = dayData.date.toISOString(); cell.setAttribute('tabindex', '0'); cell.setAttribute('role', 'button'); cell.setAttribute('aria-label', this.formatDateForTitle(dayData.date)); } container.appendChild(cell); }); // Re-attach event listeners this.attachDateCellListeners(); } // Public API Methods for external calls showSettings() { this.showTemporaryMessage('Settings panel coming soon...'); } enableDragMode() { this.showTemporaryMessage('Drag from header to move widget'); } // Additional methods for floating calendar functionality enableDragMode() { this.showTemporaryMessage('Drag mode enabled - Click and drag the header to move'); // Highlight the header to show it's draggable const header = this.widget.querySelector('.bg-gradient-to-r'); if (header) { header.style.boxShadow = '0 0 20px rgba(59, 130, 246, 0.5)'; setTimeout(() => { header.style.boxShadow = ''; }, 2000); } } showSettings() { // Create a simple settings modal const modal = document.createElement('div'); modal.className = 'fixed inset-0 z-[60] flex items-center justify-center bg-black/50'; modal.innerHTML = ` <div class="bg-white dark:bg-gray-800 rounded-xl p-6 m-4 max-w-md w-full shadow-2xl"> <div class="flex items-center justify-between mb-4"> <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Calendar Settings</h3> <button class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" onclick="this.closest('.fixed').remove()"> <i class="fas fa-times"></i> </button> </div> <div class="space-y-4"> <div> <label class="block text-sm font-medium text-