sidebar-calendar
Version:
A powerful calendar component for sidebars with day, week, and range selection modes
840 lines (746 loc) • 27.1 kB
JavaScript
/**
* 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;
}