UNPKG

sidebar-calendar

Version:

A powerful calendar component for sidebars with day, week, and range selection modes

840 lines (746 loc) 27.1 kB
/** * SidebarCalendar v1.0.0 * A powerful calendar component for sidebars with day, week, and range selection modes * * @author Your Name * @license MIT */ class SidebarCalendar { /** * Create a new SidebarCalendar instance * @param {string|HTMLElement} container - Container element or selector * @param {Object} options - Configuration options * @param {string} options.mode - Calendar mode: 'day', 'week', or 'range' * @param {Date} options.minDate - Minimum selectable date * @param {Date} options.maxDate - Maximum selectable date * @param {string} options.locale - Locale for date formatting (default: 'ru-RU') * @param {Array} options.disabledDates - Array of disabled dates * @param {Function} options.onDateSelect - Callback for date selection * @param {Function} options.onWeekSelect - Callback for week selection * @param {Function} options.onRangeSelect - Callback for range selection */ constructor(container, options = {}) { // Validate container this.container = typeof container === 'string' ? document.getElementById(container) : container; if (!this.container) { throw new Error('SidebarCalendar: Container element not found'); } // Configuration this.mode = options.mode || 'day'; this.locale = options.locale || 'ru-RU'; this.minDate = options.minDate || null; this.maxDate = options.maxDate || null; this.disabledDates = options.disabledDates || []; // State this.currentDate = new Date(); this.selectedDate = null; this.selectedWeek = { start: null, end: null }; this.selectedRange = { start: null, end: null }; this.callbacks = {}; // Localization this.monthNames = this._getMonthNames(); this.weekdayNames = this._getWeekdayNames(); // Legacy callback support if (options.onDateSelect) this.on('dateSelect', options.onDateSelect); if (options.onWeekSelect) this.on('weekSelect', options.onWeekSelect); if (options.onRangeSelect) this.on('rangeSelect', options.onRangeSelect); // Validate mode if (!['day', 'week', 'range'].includes(this.mode)) { throw new Error('SidebarCalendar: Invalid mode. Use "day", "week", or "range"'); } this.init(); } /** * Initialize the calendar * @private */ init() { this._createStructure(); this._bindEvents(); this.render(); } /** * Create the calendar HTML structure * @private */ _createStructure() { const modeClass = `${this.mode}-mode`; this.container.innerHTML = ` <div class="calendar-header ${modeClass}"> <button class="nav-button" data-nav="prev" aria-label="Previous month">‹</button> <div class="month-year" role="button" aria-label="Select month/year"></div> <button class="nav-button" data-nav="next" aria-label="Next month">›</button> </div> <div class="calendar-body"> <div class="weekdays" role="row"></div> <div class="days" role="grid" aria-label="Calendar days"></div> <div class="selected-info ${modeClass}" style="display: none;" role="status" aria-live="polite"> <h4 class="selected-title"></h4> <p class="selected-text"></p> </div> </div> <div class="quick-actions"> <div class="quick-buttons"></div> </div> `; this._createWeekdays(); this._createQuickActions(); } /** * Create weekday headers * @private */ _createWeekdays() { const weekdaysContainer = this.container.querySelector('.weekdays'); this.weekdayNames.forEach(name => { const weekday = document.createElement('div'); weekday.className = 'weekday'; weekday.textContent = name; weekday.setAttribute('role', 'columnheader'); weekdaysContainer.appendChild(weekday); }); } /** * Create quick action buttons * @private */ _createQuickActions() { const quickButtons = this.container.querySelector('.quick-buttons'); const actions = this._getQuickActions(); actions.forEach(action => { const button = document.createElement('button'); button.className = 'quick-btn'; button.textContent = action.label; button.dataset.action = action.action; button.setAttribute('type', 'button'); quickButtons.appendChild(button); }); } /** * Get quick actions based on mode * @private */ _getQuickActions() { const baseActions = [ { action: 'clear', label: 'Очистить' } ]; switch (this.mode) { case 'day': return [ { action: 'today', label: 'Сегодня' }, { action: 'tomorrow', label: 'Завтра' }, ...baseActions ]; case 'week': return [ { action: 'thisWeek', label: 'Эта неделя' }, { action: 'nextWeek', label: 'Следующая' }, ...baseActions ]; case 'range': return [ { action: 'thisMonth', label: 'Этот месяц' }, { action: 'lastWeek', label: 'Прошлая неделя' }, ...baseActions ]; default: return baseActions; } } /** * Bind event listeners * @private */ _bindEvents() { // Navigation buttons this.container.querySelectorAll('[data-nav]').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const direction = e.target.dataset.nav === 'next' ? 1 : -1; this.navigate(direction); }); }); // Quick action buttons this.container.querySelectorAll('[data-action]').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); this.quickAction(e.target.dataset.action); }); }); // Day clicks this.container.querySelector('.days').addEventListener('click', (e) => { if (e.target.classList.contains('day') && !e.target.classList.contains('disabled')) { e.preventDefault(); this.selectDate(e.target); } }); // Week hover (only for week mode) if (this.mode === 'week') { const daysContainer = this.container.querySelector('.days'); daysContainer.addEventListener('mouseover', (e) => { if (e.target.classList.contains('day')) { this._highlightWeek(e.target, true); } }); daysContainer.addEventListener('mouseout', (e) => { if (e.target.classList.contains('day')) { this._highlightWeek(e.target, false); } }); } // Keyboard navigation this.container.addEventListener('keydown', (e) => { this._handleKeyboard(e); }); } /** * Handle keyboard navigation * @private */ _handleKeyboard(e) { const { key } = e; switch (key) { case 'ArrowLeft': e.preventDefault(); this.navigate(-1); break; case 'ArrowRight': e.preventDefault(); this.navigate(1); break; case 'Escape': e.preventDefault(); this.clear(); break; } } /** * Subscribe to calendar events * @param {string} event - Event name * @param {Function} callback - Callback function * @returns {SidebarCalendar} - Returns this for chaining */ on(event, callback) { if (!this.callbacks[event]) { this.callbacks[event] = []; } this.callbacks[event].push(callback); return this; } /** * Unsubscribe from calendar events * @param {string} event - Event name * @param {Function} callback - Callback function to remove * @returns {SidebarCalendar} - Returns this for chaining */ off(event, callback) { if (this.callbacks[event]) { this.callbacks[event] = this.callbacks[event].filter(cb => cb !== callback); } return this; } /** * Emit calendar events * @private */ _emit(event, data) { if (this.callbacks[event]) { this.callbacks[event].forEach(callback => { try { callback(data); } catch (error) { console.error('SidebarCalendar callback error:', error); } }); } } /** * Navigate to previous/next month * @param {number} direction - Direction (-1 for previous, 1 for next) */ navigate(direction) { this.currentDate.setMonth(this.currentDate.getMonth() + direction); this.render(); this._emit('navigate', { direction, date: new Date(this.currentDate) }); } /** * Select a date from day element * @param {HTMLElement} dayElement - Day element that was clicked */ selectDate(dayElement) { const date = new Date(dayElement.dataset.date); if (this._isDateDisabled(date)) { return; } switch (this.mode) { case 'day': this._selectDay(date); break; case 'week': this._selectWeek(date); break; case 'range': this._selectRange(date); break; } this._updateSelectedInfo(); this.render(); } /** * Select a single day * @private */ _selectDay(date) { this.selectedDate = date; this._emit('dateSelect', { type: 'day', date: new Date(date), formatted: this._formatDate(date) }); } /** * Select a week * @private */ _selectWeek(date) { const weekRange = this._getWeekRange(date); this.selectedWeek = weekRange; this._emit('weekSelect', { type: 'week', start: new Date(weekRange.start), end: new Date(weekRange.end), formatted: `${this._formatDate(weekRange.start)} - ${this._formatDate(weekRange.end)}` }); } /** * Select a range * @private */ _selectRange(date) { if (!this.selectedRange.start || (this.selectedRange.start && this.selectedRange.end)) { // Start new range this.selectedRange = { start: date, end: null }; this._emit('rangeStart', { type: 'rangeStart', date: new Date(date), formatted: this._formatDate(date) }); } else { // Complete range if (date < this.selectedRange.start) { this.selectedRange = { start: date, end: this.selectedRange.start }; } else { this.selectedRange.end = date; } this._emit('rangeSelect', { type: 'range', start: new Date(this.selectedRange.start), end: new Date(this.selectedRange.end), formatted: `${this._formatDate(this.selectedRange.start)} - ${this._formatDate(this.selectedRange.end)}` }); } } /** * Get week range for a given date * @private */ _getWeekRange(date) { const start = new Date(date); const day = start.getDay(); const diff = start.getDate() - day + (day === 0 ? -6 : 1); // Monday start.setDate(diff); const end = new Date(start); end.setDate(start.getDate() + 6); return { start, end }; } /** * Highlight week on hover * @private */ _highlightWeek(dayElement, show) { if (show) { const date = new Date(dayElement.dataset.date); const weekRange = this._getWeekRange(date); this.container.querySelectorAll('.day').forEach(day => { const dayDate = new Date(day.dataset.date); if (dayDate >= weekRange.start && dayDate <= weekRange.end) { if (!day.classList.contains('selected-week')) { day.classList.add('week-hover'); } } }); } else { this.container.querySelectorAll('.day').forEach(day => { day.classList.remove('week-hover'); }); } } /** * Execute quick actions * @param {string} action - Action name */ quickAction(action) { const today = new Date(); switch (action) { case 'today': this.setDate(today); break; case 'tomorrow': const tomorrow = new Date(today); tomorrow.setDate(today.getDate() + 1); this.setDate(tomorrow); break; case 'thisWeek': this.setWeek(today); break; case 'nextWeek': const nextWeek = new Date(today); nextWeek.setDate(today.getDate() + 7); this.setWeek(nextWeek); break; case 'thisMonth': const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); this.setRange(startOfMonth, endOfMonth); break; case 'lastWeek': const lastWeek = new Date(today); lastWeek.setDate(today.getDate() - 7); const lastWeekRange = this._getWeekRange(lastWeek); this.setRange(lastWeekRange.start, lastWeekRange.end); break; case 'clear': this.clear(); break; } } /** * Update selected info display * @private */ _updateSelectedInfo() { const info = this.container.querySelector('.selected-info'); const title = this.container.querySelector('.selected-title'); const text = this.container.querySelector('.selected-text'); let shouldShow = false; if (this.mode === 'day' && this.selectedDate) { title.textContent = 'Выбранный день:'; text.textContent = this._formatDate(this.selectedDate); shouldShow = true; } else if (this.mode === 'week' && this.selectedWeek.start) { title.textContent = 'Выбранная неделя:'; text.textContent = `${this._formatDate(this.selectedWeek.start)} - ${this._formatDate(this.selectedWeek.end)}`; shouldShow = true; } else if (this.mode === 'range') { if (this.selectedRange.start && this.selectedRange.end) { title.textContent = 'Выбранный период:'; text.textContent = `${this._formatDate(this.selectedRange.start)} - ${this._formatDate(this.selectedRange.end)}`; shouldShow = true; } else if (this.selectedRange.start) { title.textContent = 'Выбранный период:'; text.textContent = `От ${this._formatDate(this.selectedRange.start)} (выберите конец)`; shouldShow = true; } } info.style.display = shouldShow ? 'block' : 'none'; } /** * Format date for display * @private */ _formatDate(date) { return date.toLocaleDateString(this.locale, { day: 'numeric', month: 'long', year: 'numeric' }); } /** * Render the calendar */ render() { this._updateHeader(); this._renderDays(); } /** * Update calendar header * @private */ _updateHeader() { const monthYear = this.container.querySelector('.month-year'); monthYear.textContent = `${this.monthNames[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`; } /** * Render calendar days * @private */ _renderDays() { const container = this.container.querySelector('.days'); container.innerHTML = ''; const year = this.currentDate.getFullYear(); const month = this.currentDate.getMonth(); const firstDay = new Date(year, month, 1); const today = new Date(); // Start from Monday const startDate = new Date(firstDay); const dayOfWeek = firstDay.getDay(); const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1; startDate.setDate(startDate.getDate() - daysToSubtract); // Generate 42 days (6 weeks) for (let i = 0; i < 42; i++) { const date = new Date(startDate); date.setDate(startDate.getDate() + i); const dayElement = this._createDayElement(date, month, today); container.appendChild(dayElement); } } /** * Create a day element * @private */ _createDayElement(date, currentMonth, today) { const dayElement = document.createElement('div'); dayElement.className = 'day'; dayElement.textContent = date.getDate(); dayElement.dataset.date = date.toISOString(); dayElement.setAttribute('role', 'gridcell'); dayElement.setAttribute('tabindex', '0'); dayElement.setAttribute('aria-label', this._formatDate(date)); // Add classes based on state if (date.getMonth() !== currentMonth) { dayElement.classList.add('other-month'); } if (this._isSameDay(date, today)) { dayElement.classList.add('today'); dayElement.setAttribute('aria-label', `${this._formatDate(date)} (сегодня)`); } if (this._isDateDisabled(date)) { dayElement.classList.add('disabled'); dayElement.setAttribute('aria-disabled', 'true'); } // Apply selection styles this._applySelectionStyles(dayElement, date); return dayElement; } /** * Apply selection styles to day element * @private */ _applySelectionStyles(dayElement, date) { if (this.mode === 'day' && this.selectedDate && this._isSameDay(date, this.selectedDate)) { dayElement.classList.add('selected-day'); dayElement.setAttribute('aria-selected', 'true'); } else if (this.mode === 'week' && this.selectedWeek.start && this.selectedWeek.end) { if (date >= this.selectedWeek.start && date <= this.selectedWeek.end) { dayElement.classList.add('selected-week'); dayElement.setAttribute('aria-selected', 'true'); } } else if (this.mode === 'range') { if (this.selectedRange.start && this.selectedRange.end) { if (this._isSameDay(date, this.selectedRange.start)) { dayElement.classList.add('range-start'); dayElement.setAttribute('aria-selected', 'true'); } else if (this._isSameDay(date, this.selectedRange.end)) { dayElement.classList.add('range-end'); dayElement.setAttribute('aria-selected', 'true'); } else if (date > this.selectedRange.start && date < this.selectedRange.end) { dayElement.classList.add('in-range'); dayElement.setAttribute('aria-selected', 'true'); } } else if (this.selectedRange.start && this._isSameDay(date, this.selectedRange.start)) { dayElement.classList.add('range-start'); dayElement.setAttribute('aria-selected', 'true'); } } } /** * Check if date is disabled * @private */ _isDateDisabled(date) { if (this.minDate && date < this.minDate) return true; if (this.maxDate && date > this.maxDate) return true; return this.disabledDates.some(disabledDate => this._isSameDay(date, disabledDate) ); } /** * Check if two dates are the same day * @private */ _isSameDay(date1, date2) { return date1.getDate() === date2.getDate() && date1.getMonth() === date2.getMonth() && date1.getFullYear() === date2.getFullYear(); } /** * Get localized month names * @private */ _getMonthNames() { if (this.locale === 'ru-RU') { return [ 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' ]; } // Default English return [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December' ]; } /** * Get localized weekday names * @private */ _getWeekdayNames() { if (this.locale === 'ru-RU') { return ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; } // Default English return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; } // Public API methods /** * Get currently selected value * @returns {Date|Object|null} Selected date, week range, or range */ getSelected() { switch (this.mode) { case 'day': return this.selectedDate; case 'week': return this.selectedWeek; case 'range': return this.selectedRange; default: return null; } } /** * Set selected date (day mode only) * @param {Date|string} date - Date to select */ setDate(date) { if (this.mode !== 'day') { console.warn('setDate() only works in day mode'); return; } const dateObj = new Date(date); if (this._isDateDisabled(dateObj)) { console.warn('Cannot select disabled date'); return; } this.selectedDate = dateObj; this.currentDate = new Date(dateObj); this._emit('dateSelect', { type: 'day', date: new Date(this.selectedDate), formatted: this._formatDate(this.selectedDate) }); this._updateSelectedInfo(); this.render(); } /** * Set selected week (week mode only) * @param {Date|string} date - Date within the week to select */ setWeek(date) { if (this.mode !== 'week') { console.warn('setWeek() only works in week mode'); return; } const dateObj = new Date(date); this.selectedWeek = this._getWeekRange(dateObj); this.currentDate = new Date(dateObj); this._emit('weekSelect', { type: 'week', start: new Date(this.selectedWeek.start), end: new Date(this.selectedWeek.end), formatted: `${this._formatDate(this.selectedWeek.start)} - ${this._formatDate(this.selectedWeek.end)}` }); this._updateSelectedInfo(); this.render(); } /** * Set selected range (range mode only) * @param {Date|string} start - Start date * @param {Date|string} end - End date */ setRange(start, end) { if (this.mode !== 'range') { console.warn('setRange() only works in range mode'); return; } const startDate = new Date(start); const endDate = new Date(end); if (startDate > endDate) { console.warn('Start date cannot be after end date'); return; } this.selectedRange = { start: startDate, end: endDate }; this.currentDate = new Date(startDate); this._emit('rangeSelect', { type: 'range', start: new Date(this.selectedRange.start), end: new Date(this.selectedRange.end), formatted: `${this._formatDate(this.selectedRange.start)} - ${this._formatDate(this.selectedRange.end)}` }); this._updateSelectedInfo(); this.render(); } /** * Clear all selections */ clear() { this.selectedDate = null; this.selectedWeek = { start: null, end: null }; this.selectedRange = { start: null, end: null }; this._emit('clear', { type: 'clear' }); this._updateSelectedInfo(); this.render(); } /** * Go to specific month/year * @param {Date|string} date - Date to navigate to */ goToDate(date) { this.currentDate = new Date(date); this.render(); } /** * Destroy the calendar instance */ destroy() { // Remove all event listeners this.callbacks = {}; // Clear container if (this.container) { this.container.innerHTML = ''; } this._emit('destroy', { type: 'destroy' }); } /** * Get calendar version * @static */ static get version() { return '1.0.0'; } } // Export for different module systems if (typeof module !== 'undefined' && module.exports) { module.exports = SidebarCalendar; } else if (typeof define === 'function' && define.amd) { define([], function() { return SidebarCalendar; }); } else { window.SidebarCalendar = SidebarCalendar; }