diff --git a/js/src/calendar.js b/js/src/calendar.js index 2953035a4..db0df4cc0 100644 --- a/js/src/calendar.js +++ b/js/src/calendar.js @@ -23,7 +23,13 @@ import { isDateInRange, isDateSelected, isDisableDateInRange, - isToday + isMonthDisabled, + isMonthInRange, + isMonthSelected, + isToday, + isYearDisabled, + isYearInRange, + isYearSelected } from './util/calendar.js' /** @@ -60,6 +66,7 @@ const CLASS_NAME_CALENDAR_CELL = 'calendar-cell' const CLASS_NAME_CALENDAR_CELL_INNER = 'calendar-cell-inner' const CLASS_NAME_CALENDAR_ROW = 'calendar-row' const CLASS_NAME_CALENDARS = 'calendars' +const CLASS_NAME_SHOW_WEEK_NUMBERS = 'show-week-numbers' const SELECTOR_BTN_DOUBLE_NEXT = '.btn-double-next' const SELECTOR_BTN_DOUBLE_PREV = '.btn-double-prev' @@ -69,7 +76,9 @@ const SELECTOR_BTN_PREV = '.btn-prev' const SELECTOR_BTN_YEAR = '.btn-year' const SELECTOR_CALENDAR = '.calendar' const SELECTOR_CALENDAR_CELL = '.calendar-cell' +const SELECTOR_CALENDAR_CELL_CLICKABLE = `${SELECTOR_CALENDAR_CELL}[tabindex="0"]` const SELECTOR_CALENDAR_ROW = '.calendar-row' +const SELECTOR_CALENDAR_ROW_CLICKABLE = `${SELECTOR_CALENDAR_ROW}[tabindex="0"]` const SELECTOR_DATA_TOGGLE = '[data-coreui-toggle="calendar"]' const Default = { @@ -129,26 +138,8 @@ class Calendar extends BaseComponent { super(element) this._config = this._getConfig(config) - this._calendarDate = convertToDateObject( - this._config.calendarDate || this._config.startDate || this._config.endDate || new Date(), this._config.selectionType - ) - this._startDate = convertToDateObject(this._config.startDate, this._config.selectionType) - this._endDate = convertToDateObject(this._config.endDate, this._config.selectionType) - this._hoverDate = null - this._selectEndDate = this._config.selectEndDate - - if (this._config.selectionType === 'day' || this._config.selectionType === 'week') { - this._view = 'days' - } - - if (this._config.selectionType === 'month') { - this._view = 'months' - } - - if (this._config.selectionType === 'year') { - this._view = 'years' - } - + this._initializeDates() + this._initializeView() this._createCalendar() this._addEventListeners() } @@ -169,31 +160,23 @@ class Calendar extends BaseComponent { // Public update(config) { this._config = this._getConfig(config) - this._calendarDate = convertToDateObject( - this._config.calendarDate || this._config.startDate || this._config.endDate || new Date(), this._config.selectionType - ) - this._startDate = convertToDateObject(this._config.startDate, this._config.selectionType) - this._endDate = convertToDateObject(this._config.endDate, this._config.selectionType) - this._hoverDate = null - this._selectEndDate = this._config.selectEndDate - - if (this._config.selectionType === 'day' || this._config.selectionType === 'week') { - this._view = 'days' - } - - if (this._config.selectionType === 'month') { - this._view = 'months' - } - - if (this._config.selectionType === 'year') { - this._view = 'years' - } + this._initializeDates() + this._initializeView() + // Clear the current calendar content this._element.innerHTML = '' this._createCalendar() } // Private + _focusOnFirstAvailableCell() { + const cell = SelectorEngine.findOne(SELECTOR_CALENDAR_CELL_CLICKABLE, this._element) + + if (cell) { + cell.focus() + } + } + _getDate(target) { if (this._config.selectionType === 'week') { const firstCell = SelectorEngine.findOne(SELECTOR_CALENDAR_CELL, target.closest(SELECTOR_CALENDAR_ROW)) @@ -209,10 +192,6 @@ class Calendar extends BaseComponent { const cloneDate = new Date(date) const index = Manipulator.getDataAttribute(target.closest(SELECTOR_CALENDAR), 'calendar-index') - if (isDateDisabled(date, this._config.minDate, this._config.maxDate, this._config.disabledDates)) { - return - } - if (this._view === 'days') { this._setCalendarDate(index ? new Date(cloneDate.setMonth(cloneDate.getMonth() - index)) : date) } @@ -220,14 +199,19 @@ class Calendar extends BaseComponent { if (this._view === 'months' && this._config.selectionType !== 'month') { this._setCalendarDate(index ? new Date(cloneDate.setMonth(cloneDate.getMonth() - index)) : date) this._view = 'days' - this._updateCalendar() + this._updateCalendar(this._focusOnFirstAvailableCell.bind(this)) return } if (this._view === 'years' && this._config.selectionType !== 'year') { this._setCalendarDate(index ? new Date(cloneDate.setFullYear(cloneDate.getFullYear() - index)) : date) this._view = 'months' - this._updateCalendar() + this._updateCalendar(this._focusOnFirstAvailableCell.bind(this)) + return + } + + // Allow to change the calendarDate but not startDate or endDate + if (isDateDisabled(date, this._config.minDate, this._config.maxDate, this._config.disabledDates)) { return } @@ -271,11 +255,11 @@ class Calendar extends BaseComponent { let element = event.target if (this._config.selectionType === 'week' && element.tabIndex === -1) { - element = element.closest('tr[tabindex="0"]') + element = element.closest(SELECTOR_CALENDAR_ROW_CLICKABLE) } const list = SelectorEngine.find( - this._config.selectionType === 'week' ? 'tr[tabindex="0"]' : 'td[tabindex="0"]', + this._config.selectionType === 'week' ? SELECTOR_CALENDAR_ROW_CLICKABLE : SELECTOR_CALENDAR_CELL_CLICKABLE, this._element ) @@ -302,40 +286,35 @@ class Calendar extends BaseComponent { (event.key === ARROW_UP_KEY && toBoundary.start < Math.abs(gap.ArrowUp)) ) { const callback = key => { - setTimeout(() => { - const _list = SelectorEngine.find( - 'td[tabindex="0"], tr[tabindex="0"]', - SelectorEngine.find('.calendar', this._element).pop() - ) - - if (_list.length && key === ARROW_RIGHT_KEY) { - _list[0].focus() - } - - if (_list.length && key === ARROW_LEFT_KEY) { - _list[_list.length - 1].focus() - } - - if (_list.length && key === ARROW_DOWN_KEY) { - _list[gap.ArrowDown - (list.length - index)].focus() - } - - if (_list.length && key === ARROW_UP_KEY) { - _list[_list.length - (Math.abs(gap.ArrowUp) + 1 - (index + 1))].focus() - } - }, 0) + const _list = SelectorEngine.find(`${SELECTOR_CALENDAR_CELL_CLICKABLE}, ${SELECTOR_CALENDAR_ROW_CLICKABLE}`, this._element) + + if (_list.length && key === ARROW_RIGHT_KEY) { + _list[0].focus() + } + + if (_list.length && key === ARROW_LEFT_KEY) { + _list[_list.length - 1].focus() + } + + if (_list.length && key === ARROW_DOWN_KEY) { + _list[gap.ArrowDown - (list.length - index)].focus() + } + + if (_list.length && key === ARROW_UP_KEY) { + _list[_list.length - (Math.abs(gap.ArrowUp) + 1 - (index + 1))].focus() + } } if (this._view === 'days') { - this._modifyCalendarDate(0, event.key === ARROW_RIGHT_KEY || event.key === ARROW_DOWN_KEY ? 1 : -1, callback(event.key)) + this._modifyCalendarDate(0, event.key === ARROW_RIGHT_KEY || event.key === ARROW_DOWN_KEY ? 1 : -1, callback.bind(this, event.key)) } if (this._view === 'months') { - this._modifyCalendarDate(event.key === ARROW_RIGHT_KEY || event.key === ARROW_DOWN_KEY ? 1 : -1, callback(event.key)) + this._modifyCalendarDate(event.key === ARROW_RIGHT_KEY || event.key === ARROW_DOWN_KEY ? 1 : -1, 0, callback.bind(this, event.key)) } if (this._view === 'years') { - this._modifyCalendarDate(event.key === ARROW_RIGHT_KEY || event.key === ARROW_DOWN_KEY ? 10 : -10, callback(event.key)) + this._modifyCalendarDate(event.key === ARROW_RIGHT_KEY || event.key === ARROW_DOWN_KEY ? 10 : -10, 0, callback.bind(this, event.key)) } return @@ -383,92 +362,94 @@ class Calendar extends BaseComponent { } _addEventListeners() { - EventHandler.on(this._element, EVENT_CLICK_DATA_API, `${SELECTOR_CALENDAR_CELL}[tabindex="0"]`, event => { + EventHandler.on(this._element, EVENT_CLICK_DATA_API, SELECTOR_CALENDAR_CELL_CLICKABLE, event => { this._handleCalendarClick(event) }) - EventHandler.on(this._element, EVENT_KEYDOWN, `${SELECTOR_CALENDAR_CELL}[tabindex="0"]`, event => { + EventHandler.on(this._element, EVENT_KEYDOWN, SELECTOR_CALENDAR_CELL_CLICKABLE, event => { this._handleCalendarKeydown(event) }) - EventHandler.on(this._element, EVENT_MOUSEENTER, `${SELECTOR_CALENDAR_CELL}[tabindex="0"]`, event => { + EventHandler.on(this._element, EVENT_MOUSEENTER, SELECTOR_CALENDAR_CELL_CLICKABLE, event => { this._handleCalendarMouseEnter(event) }) - EventHandler.on(this._element, EVENT_MOUSELEAVE, `${SELECTOR_CALENDAR_CELL}[tabindex="0"]`, () => { + EventHandler.on(this._element, EVENT_MOUSELEAVE, SELECTOR_CALENDAR_CELL_CLICKABLE, () => { this._handleCalendarMouseLeave() }) - EventHandler.on(this._element, EVENT_FOCUS, `${SELECTOR_CALENDAR_CELL}[tabindex="0"]`, event => { + EventHandler.on(this._element, EVENT_FOCUS, SELECTOR_CALENDAR_CELL_CLICKABLE, event => { this._handleCalendarMouseEnter(event) }) - EventHandler.on(this._element, EVENT_BLUR, `${SELECTOR_CALENDAR_CELL}[tabindex="0"]`, () => { + EventHandler.on(this._element, EVENT_BLUR, SELECTOR_CALENDAR_CELL_CLICKABLE, () => { this._handleCalendarMouseLeave() }) - EventHandler.on(this._element, EVENT_CLICK_DATA_API, `${SELECTOR_CALENDAR_ROW}[tabindex="0"]`, event => { + EventHandler.on(this._element, EVENT_CLICK_DATA_API, SELECTOR_CALENDAR_ROW_CLICKABLE, event => { this._handleCalendarClick(event) }) - EventHandler.on(this._element, EVENT_KEYDOWN, `${SELECTOR_CALENDAR_ROW}[tabindex="0"]`, event => { + EventHandler.on(this._element, EVENT_KEYDOWN, SELECTOR_CALENDAR_ROW_CLICKABLE, event => { this._handleCalendarKeydown(event) }) - EventHandler.on(this._element, EVENT_MOUSEENTER, `${SELECTOR_CALENDAR_ROW}[tabindex="0"]`, event => { + EventHandler.on(this._element, EVENT_MOUSEENTER, SELECTOR_CALENDAR_ROW_CLICKABLE, event => { this._handleCalendarMouseEnter(event) }) - EventHandler.on(this._element, EVENT_MOUSELEAVE, `${SELECTOR_CALENDAR_ROW}[tabindex="0"]`, () => { + EventHandler.on(this._element, EVENT_MOUSELEAVE, SELECTOR_CALENDAR_ROW_CLICKABLE, () => { this._handleCalendarMouseLeave() }) - EventHandler.on(this._element, EVENT_FOCUS, `${SELECTOR_CALENDAR_ROW}[tabindex="0"]`, event => { + EventHandler.on(this._element, EVENT_FOCUS, SELECTOR_CALENDAR_ROW_CLICKABLE, event => { this._handleCalendarMouseEnter(event) }) - EventHandler.on(this._element, EVENT_BLUR, `${SELECTOR_CALENDAR_ROW}[tabindex="0"]`, () => { + EventHandler.on(this._element, EVENT_BLUR, SELECTOR_CALENDAR_ROW_CLICKABLE, () => { this._handleCalendarMouseLeave() }) // Navigation - EventHandler.on(this._element, EVENT_CLICK_DATA_API, SELECTOR_BTN_PREV, event => { - event.preventDefault() - this._modifyCalendarDate(0, -1) - }) - - EventHandler.on(this._element, EVENT_CLICK_DATA_API, SELECTOR_BTN_DOUBLE_PREV, event => { - event.preventDefault() - this._modifyCalendarDate(this._view === 'years' ? -10 : -1) - }) - - EventHandler.on(this._element, EVENT_CLICK_DATA_API, SELECTOR_BTN_NEXT, event => { - event.preventDefault() - this._modifyCalendarDate(0, 1) - }) - - EventHandler.on(this._element, EVENT_CLICK_DATA_API, SELECTOR_BTN_DOUBLE_NEXT, event => { - event.preventDefault() - this._modifyCalendarDate(this._view === 'years' ? 10 : 1) - }) - - EventHandler.on(this._element, EVENT_CLICK_DATA_API, SELECTOR_BTN_MONTH, event => { - event.preventDefault() - this._view = 'months' - this._updateCalendar() - }) - - EventHandler.on(this._element, EVENT_CLICK_DATA_API, SELECTOR_BTN_YEAR, event => { - event.preventDefault() - this._view = 'years' - this._updateCalendar() - }) + this._addNavigationEventListeners() EventHandler.on(this._element, EVENT_MOUSELEAVE, 'table', () => { EventHandler.trigger(this._element, EVENT_CALENDAR_MOUSE_LEAVE) }) } + _addNavigationEventListeners() { + const navigationSelectors = { + [SELECTOR_BTN_PREV]: () => this._modifyCalendarDate(0, -1), + [SELECTOR_BTN_DOUBLE_PREV]: () => this._modifyCalendarDate(this._view === 'years' ? -10 : -1), + [SELECTOR_BTN_NEXT]: () => this._modifyCalendarDate(0, 1), + [SELECTOR_BTN_DOUBLE_NEXT]: () => this._modifyCalendarDate(this._view === 'years' ? 10 : 1), + [SELECTOR_BTN_MONTH]: () => { + this._view = 'months' + this._updateCalendar() + }, + [SELECTOR_BTN_YEAR]: () => { + this._view = 'years' + this._updateCalendar() + } + } + + for (const [selector, handler] of Object.entries(navigationSelectors)) { + EventHandler.on(this._element, EVENT_CLICK_DATA_API, selector, event => { + event.preventDefault() + const selectors = SelectorEngine.find(selector, this._element) + const selectorIndex = selectors.indexOf(event.target.closest(selector)) + handler() + + // Retrieve focus to the navigation element + const _selectors = SelectorEngine.find(selector, this._element) + if (_selectors && _selectors[selectorIndex]) { + _selectors[selectorIndex].focus() + } + }) + } + } + _setCalendarDate(date) { this._calendarDate = date @@ -618,14 +599,14 @@ class Calendar extends BaseComponent { ${this._config.showWeekNumber ? - ` + `
${this._config.weekNumbersLabel ?? ''}
` : '' } ${weekDays.map(({ date }) => ( - ` + `
${typeof this._config.weekdayFormat === 'string' ? date.toLocaleDateString(this._config.locale, { weekday: this._config.weekdayFormat }) : @@ -645,49 +626,45 @@ class Calendar extends BaseComponent { `${calendarDate.getFullYear()}W${week.weekNumber}`, this._config.selectionType ) + const rowAttributes = this._rowWeekAttributes(date) return ( ` ${this._config.showWeekNumber ? `${week.weekNumber === 0 ? 53 : week.weekNumber}` : '' } - ${week.days.map(({ date, month }) => ( - month === 'current' || this._config.showAdjacementDays ? - ` -
- ${date.toLocaleDateString(this._config.locale, { day: 'numeric' })} -
- ` : - '' - )).join('')}` + ${week.days.map(({ date, month }) => { + const cellAttributes = this._cellDayAttributes(date, month) + return month === 'current' || this._config.showAdjacementDays ? + ` +
+ ${date.toLocaleDateString(this._config.locale, { day: 'numeric' })} +
+ ` : + '' + } + ).join('')}` ) }).join('') : ''} ${this._view === 'months' ? listOfMonths.map((row, index) => ( ` ${row.map((month, idx) => { const date = new Date(calendarDate.getFullYear(), (index * 3) + idx, 1) + const cellAttributes = this._cellMonthAttributes(date) return ( `
${month} @@ -701,12 +678,13 @@ class Calendar extends BaseComponent { ` ${row.map(year => { const date = new Date(year, 0, 1) + const cellAttributes = this._cellYearAttributes(date) return ( `
${year} @@ -729,7 +707,7 @@ class Calendar extends BaseComponent { } if (this._config.showWeekNumber) { - this._element.classList.add('show-week-numbers') + this._element.classList.add(CLASS_NAME_SHOW_WEEK_NUMBERS) } for (const [index, _] of Array.from({ length: this._config.calendars }).entries()) { @@ -739,12 +717,34 @@ class Calendar extends BaseComponent { this._element.classList.add(CLASS_NAME_CALENDARS) } + _initializeDates() { + // Convert dates to date objects based on the selection type + this._calendarDate = convertToDateObject( + this._config.calendarDate || this._config.startDate || this._config.endDate || new Date(), this._config.selectionType + ) + this._startDate = convertToDateObject(this._config.startDate, this._config.selectionType) + this._endDate = convertToDateObject(this._config.endDate, this._config.selectionType) + this._hoverDate = null + this._selectEndDate = this._config.selectEndDate + } + + _initializeView() { + const viewMap = { + day: 'days', + week: 'days', + month: 'months', + year: 'years' + } + + this._view = viewMap[this._config.selectionType] || 'days' + } + _updateCalendar(callback) { this._element.innerHTML = '' this._createCalendar() if (callback) { - callback() + setTimeout(callback, 1) } } @@ -755,11 +755,12 @@ class Calendar extends BaseComponent { for (const row of rows) { const firstCell = SelectorEngine.findOne(SELECTOR_CALENDAR_CELL, row) const date = new Date(Manipulator.getDataAttribute(firstCell, 'date')) - const classNames = this._sharedClassNames(date) + const rowAttributes = this._rowWeekAttributes(date) - row.className = `${CLASS_NAME_CALENDAR_ROW} ${classNames}` + row.className = rowAttributes.className + row.tabIndex = rowAttributes.tabIndex - if (isDateSelected(date, this._startDate, this._endDate)) { + if (rowAttributes.ariaSelected) { row.setAttribute('aria-selected', true) } else { row.removeAttribute('aria-selected') @@ -769,15 +770,24 @@ class Calendar extends BaseComponent { return } - const cells = SelectorEngine.find(`${SELECTOR_CALENDAR_CELL}[tabindex="0"]`, this._element) + const cells = SelectorEngine.find(SELECTOR_CALENDAR_CELL_CLICKABLE, this._element) for (const cell of cells) { const date = new Date(Manipulator.getDataAttribute(cell, 'date')) - const classNames = this._config.selectionType === 'day' ? this._dayClassNames(date, 'current') : this._sharedClassNames(date) + let cellAttributes - cell.className = `${CLASS_NAME_CALENDAR_CELL} ${classNames}` + if (this._view === 'days') { + cellAttributes = this._cellDayAttributes(date, 'current') + } else if (this._view === 'months') { + cellAttributes = this._cellMonthAttributes(date) + } else { + cellAttributes = this._cellYearAttributes(date) + } - if (isDateSelected(date, this._startDate, this._endDate)) { + cell.className = cellAttributes.className + cell.tabIndex = cellAttributes.tabIndex + + if (cellAttributes.ariaSelected) { cell.setAttribute('aria-selected', true) } else { cell.removeAttribute('aria-selected') @@ -785,56 +795,107 @@ class Calendar extends BaseComponent { } } - _dayClassNames(date, month) { - const classNames = { + _classNames(classNames) { + return Object.entries(classNames) + .filter(([_, value]) => Boolean(value)) + .map(([key]) => key) + .join(' ') + } + + _cellDayAttributes(date, month) { + const isCurrentMonth = month === 'current' + const isDisabled = isDateDisabled(date, this._config.minDate, this._config.maxDate, this._config.disabledDates) + const isSelected = isDateSelected(date, this._startDate, this._endDate) + + const classNames = this._classNames({ + [CLASS_NAME_CALENDAR_CELL]: true, ...(this._config.selectionType === 'day' && this._view === 'days' && { - clickable: month !== 'current' && this._config.selectAdjacementDays, - disabled: isDateDisabled(date, this._config.minDate, this._config.maxDate, this._config.disabledDates), - range: month === 'current' && isDateInRange(date, this._startDate, this._endDate), - 'range-hover': month === 'current' && + clickable: !isCurrentMonth && this._config.selectAdjacementDays, + disabled: isDisabled, + range: isCurrentMonth && isDateInRange(date, this._startDate, this._endDate), + 'range-hover': isCurrentMonth && (this._hoverDate && this._selectEndDate ? isDateInRange(date, this._startDate, this._hoverDate) : isDateInRange(date, this._hoverDate, this._endDate)), - selected: isDateSelected(date, this._startDate, this._endDate) + selected: isSelected }), today: isToday(date), [month]: true + }) + + return { + className: classNames, + tabIndex: this._config.selectionType === 'day' && + (isCurrentMonth || this._config.selectAdjacementDays) && + !isDisabled ? 0 : -1, + ariaSelected: isSelected } + } - const result = {} + _cellMonthAttributes(date) { + const isDisabled = isMonthDisabled(date, this._config.minDate, this._config.maxDate, this._config.disabledDates) + const isSelected = isMonthSelected(date, this._startDate, this._endDate) + + const classNames = this._classNames({ + [CLASS_NAME_CALENDAR_CELL]: true, + disabled: isDisabled, + 'range-hover': this._config.selectionType === 'month' && + (this._hoverDate && this._selectEndDate ? + isMonthInRange(date, this._startDate, this._hoverDate) : + isMonthInRange(date, this._hoverDate, this._endDate)), + range: isMonthInRange(date, this._startDate, this._endDate), + selected: isSelected + }) - for (const key in classNames) { - if (classNames[key] === true) { - result[key] = true - } + return { + className: classNames, + tabIndex: isDisabled ? -1 : 0, + ariaSelected: isSelected } + } - return Object.keys(result).join(' ') - } - - _sharedClassNames(date) { - const classNames = { - disabled: isDateDisabled(date, this._config.minDate, this._config.maxDate, this._config.disabledDates), - range: isDateInRange(date, this._startDate, this._endDate), - 'range-hover': ( - (this._config.selectionType === 'week' && this._view === 'days') || - (this._config.selectionType === 'month' && this._view === 'months') || - (this._config.selectionType === 'year' && this._view === 'years') - ) && (this._hoverDate && this._selectEndDate ? - isDateInRange(date, this._startDate, this._hoverDate) : - isDateInRange(date, this._hoverDate, this._endDate)), - selected: isDateSelected(date, this._startDate, this._endDate) + _cellYearAttributes(date) { + const isDisabled = isYearDisabled(date, this._config.minDate, this._config.maxDate, this._config.disabledDates) + const isSelected = isYearSelected(date, this._startDate, this._endDate) + + const classNames = this._classNames({ + [CLASS_NAME_CALENDAR_CELL]: true, + disabled: isDisabled, + 'range-hover': this._config.selectionType === 'year' && + (this._hoverDate && this._selectEndDate ? + isYearInRange(date, this._startDate, this._hoverDate) : + isYearInRange(date, this._hoverDate, this._endDate)), + range: isYearInRange(date, this._startDate, this._endDate), + selected: isSelected + }) + + return { + className: classNames, + tabIndex: isDisabled ? -1 : 0, + ariaSelected: isSelected } + } - const result = {} + _rowWeekAttributes(date) { + const isDisabled = isDateDisabled(date, this._config.minDate, this._config.maxDate, this._config.disabledDates) + const isSelected = isDateSelected(date, this._startDate, this._endDate) + + const classNames = this._classNames({ + [CLASS_NAME_CALENDAR_ROW]: true, + disabled: isDisabled, + range: this._config.selectionType === 'week' && isDateInRange(date, this._startDate, this._endDate), + 'range-hover': this._config.selectionType === 'week' && + (this._hoverDate && this._selectEndDate ? + isYearInRange(date, this._startDate, this._hoverDate) : + isYearInRange(date, this._hoverDate, this._endDate)), + selected: isSelected + }) - for (const key in classNames) { - if (classNames[key] === true) { - result[key] = true - } + return { + className: classNames, + tabIndex: this._config.selectionType === 'week' && !isDisabled ? 0 : -1, + ariaSelected: isSelected } - - return Object.keys(result).join(' ') } // Static diff --git a/js/src/date-range-picker.js b/js/src/date-range-picker.js index 74139fd88..f216031dd 100644 --- a/js/src/date-range-picker.js +++ b/js/src/date-range-picker.js @@ -13,7 +13,8 @@ import EventHandler from './dom/event-handler.js' import Manipulator from './dom/manipulator.js' import SelectorEngine from './dom/selector-engine.js' import { defineJQueryPlugin, getElement, isRTL } from './util/index.js' -import { convertToDateObject, getLocalDateFromString } from './util/calendar.js' +import { convertToDateObject } from './util/calendar.js' +import { getLocalDateFromString } from './util/date-range-picker.js' /** * Constants diff --git a/js/src/util/calendar.js b/js/src/util/calendar.js index fb02c1249..4c80d40f4 100644 --- a/js/src/util/calendar.js +++ b/js/src/util/calendar.js @@ -1,15 +1,23 @@ +/** + * Converts an ISO week string to a Date object representing the Monday of that week. + * @param isoWeek - The ISO week string (e.g., "2023W05" or "2023w05"). + * @returns The Date object for the Monday of the specified week, or null if invalid. + */ export const convertIsoWeekToDate = isoWeek => { - const [year, week] = isoWeek.split(/w/i) - // Get date for 4th of January for year - const date = new Date(Number(year), 0, 4) - // Get previous Monday, add 7 days for each week after first + const [year, week] = isoWeek.split(/[Ww]/) + const date = new Date(Number(year), 0, 4) // 4th Jan is always in week 1 date.setDate( - // eslint-disable-next-line no-mixed-operators - date.getDate() - (date.getDay() || 7) + 1 + (Number(week) - 1) * 7 + date.getDate() - (date.getDay() || 7) + 1 + ((Number(week) - 1) * 7) ) return date } +/** + * Converts a date string or Date object to a Date object based on selection type. + * @param date - The date to convert. + * @param selectionType - The type of selection ('day', 'week', 'month', 'year'). + * @returns The corresponding Date object or null if invalid. + */ export const convertToDateObject = (date, selectionType) => { if (date === null) { return null @@ -32,12 +40,12 @@ export const convertToDateObject = (date, selectionType) => { return new Date(Date.parse(date)) } -export const convertToLocalDate = (d, locale, options = {}) => - d.toLocaleDateString(locale, options) - -export const convertToLocalTime = (d, locale, options = {}) => - d.toLocaleTimeString(locale, options) - +/** + * Creates groups from an array. + * @param arr - The array to group. + * @param numberOfGroups - Number of groups to create. + * @returns An array of grouped arrays. + */ export const createGroupsInArray = (arr, numberOfGroups) => { const perGroup = Math.ceil(arr.length / numberOfGroups) return Array.from({ length: numberOfGroups }) @@ -45,26 +53,47 @@ export const createGroupsInArray = (arr, numberOfGroups) => { .map((_, i) => arr.slice(i * perGroup, (i + 1) * perGroup)) } +/** + * Adjusts the calendar date based on order and view type. + * @param calendarDate - The current calendar date. + * @param order - The order to adjust by. + * @param view - The current view type. + * @returns The adjusted Date object. + */ export const getCalendarDate = (calendarDate, order, view) => { if (order !== 0 && view === 'days') { - return new Date(calendarDate.getFullYear(), calendarDate.getMonth() + order, 1) + return new Date( + calendarDate.getFullYear(), + calendarDate.getMonth() + order, + 1 + ) } if (order !== 0 && view === 'months') { - return new Date(calendarDate.getFullYear() + order, calendarDate.getMonth(), 1) + return new Date( + calendarDate.getFullYear() + order, + calendarDate.getMonth(), + 1 + ) } if (order !== 0 && view === 'years') { - return new Date(calendarDate.getFullYear() + (12 * order), calendarDate.getMonth(), 1) + return new Date( + calendarDate.getFullYear() + (12 * order), + calendarDate.getMonth(), + 1 + ) } return calendarDate } -export const getCurrentYear = () => new Date().getFullYear() - -export const getCurrentMonth = () => new Date().getMonth() - +/** + * Formats a date based on the selection type. + * @param date - The date to format. + * @param selectionType - The type of selection ('day', 'week', 'month', 'year'). + * @returns A formatted date string or the original Date object. + */ export const getDateBySelectionType = (date, selectionType) => { if (date === null) { return null @@ -86,35 +115,89 @@ export const getDateBySelectionType = (date, selectionType) => { return date } -export const getMonthName = (month, locale) => { - const d = new Date() - d.setDate(1) - d.setMonth(month) - return d.toLocaleString(locale, { month: 'long' }) -} - -export const getMonthsNames = locale => { - const months = [] - const d = new Date() - d.setDate(1) +/** + * Retrieves the first available date within a range that is not disabled. + * @param startDate - Start date of the range. + * @param endDate - End date of the range. + * @param min - Minimum allowed date. + * @param max - Maximum allowed date. + * @param disabledDates - Criteria for disabled dates. + * @returns The first available Date object or null if none found. + */ +export const getFirstAvailableDateInRange = ( + startDate, + endDate, + min, + max, + disabledDates +) => { + const _min = min ? + new Date(Math.max(startDate.getTime(), min.getTime())) : + startDate + const _max = max ? + new Date(Math.min(endDate.getTime(), max.getTime())) : + endDate + + if (disabledDates === undefined) { + return _min + } - for (let i = 0; i < 12; i++) { - d.setMonth(i) - months.push(d.toLocaleString(locale, { month: 'short' })) + for ( + const currentDate = new Date(_min); + // eslint-disable-next-line no-unmodified-loop-condition + currentDate <= _max; + currentDate.setDate(currentDate.getDate() + 1) + ) { + if (!isDateDisabled(currentDate, min, max, disabledDates)) { + return currentDate + } } - return months + return null } -export const getYears = year => { - const years = [] - for (let _year = year - 6; _year < year + 6; _year++) { - years.push(_year) - } +/** + * Retrieves an array of month names based on locale and format. + * @param locale - The locale string (e.g., 'en-US'). + * @param format - The format of the month names ('short' or 'long'). + * @returns An array of month names. + */ +export const getMonthsNames = (locale, format = 'short') => { + return Array.from({ length: 12 }, (_, i) => { + return new Date(2000, i, 1).toLocaleString(locale, { month: format }) + }) +} + +/** + * Retrieves an array of selectable dates from the given element. + * @param element - The HTML element to search for selectable dates. + * @param selector - The CSS selector used to identify selectable dates. Defaults to 'tr[tabindex="0"], td[tabindex="0"]'. + * @returns An array of HTMLElements representing the selectable dates. + */ +export const getSelectableDates = ( + element, + selector = 'tr[tabindex="0"], td[tabindex="0"]' +) => { + return [...Element.prototype.querySelectorAll.call(element, selector)] +} - return years +/** + * Generates an array of years centered around a given year. + * @param year - The central year. + * @param range - The number of years before and after the central year. + * @returns An array of years. + */ +export const getYears = (year, range = 6) => { + return Array.from({ length: range * 2 }, (_, i) => year - range + i) } +/** + * Retrieves leading days (from the previous month) for a calendar view. + * @param year - The year. + * @param month - The month (0-11). + * @param firstDayOfWeek - The first day of the week (0-6, where 0 is Sunday). + * @returns An array of leading day objects. + */ const getLeadingDays = (year, month, firstDayOfWeek) => { // 0: sunday // 1: monday @@ -139,6 +222,12 @@ const getLeadingDays = (year, month, firstDayOfWeek) => { return dates } +/** + * Retrieves all days within a specific month. + * @param year - The year. + * @param month - The month (0-11). + * @returns An array of day objects. + */ const getMonthDays = (year, month) => { const dates = [] const lastDay = new Date(year, month + 1, 0).getDate() @@ -152,6 +241,14 @@ const getMonthDays = (year, month) => { return dates } +/** + * Retrieves trailing days (from the next month) for a calendar view. + * @param year - The year. + * @param month - The month (0-11). + * @param leadingDays - Array of leading day objects. + * @param monthDays - Array of current month day objects. + * @returns An array of trailing day objects. + */ const getTrailingDays = (year, month, leadingDays, monthDays) => { const dates = [] const days = 42 - (leadingDays.length + monthDays.length) @@ -165,53 +262,34 @@ const getTrailingDays = (year, month, leadingDays, monthDays) => { return dates } -export const getDayNumber = date => - Math.ceil((Number(date) - Number(new Date(date.getFullYear(), 0, 0))) / 1000 / 60 / 60 / 24) - -export const getLocalDateFromString = (string, locale, time) => { - const date = new Date(2013, 11, 31, 17, 19, 22) - let regex = time ? date.toLocaleString(locale) : date.toLocaleDateString(locale) - regex = regex - .replace('2013', '(?[0-9]{2,4})') - .replace('12', '(?[0-9]{1,2})') - .replace('31', '(?[0-9]{1,2})') - if (time) { - regex = regex - .replace('5', '(?[0-9]{1,2})') - .replace('17', '(?[0-9]{1,2})') - .replace('19', '(?[0-9]{1,2})') - .replace('22', '(?[0-9]{1,2})') - .replace('PM', '(?[A-Z]{2})') - } - - const rgx = new RegExp(`${regex}`) - const partials = string.match(rgx) - if (partials === null) { - return - } - - const newDate = partials.groups && - (time ? - new Date(Number(partials.groups.year, 10), Number(partials.groups.month, 10) - 1, Number(partials.groups.day), partials.groups.ampm ? - (partials.groups.ampm === 'PM' ? - Number(partials.groups.hour) + 12 : - Number(partials.groups.hour)) : - Number(partials.groups.hour), Number(partials.groups.minute), Number(partials.groups.second)) : - new Date(Number(partials.groups.year), Number(partials.groups.month) - 1, Number(partials.groups.day))) - return newDate -} - +/** + * Calculates the ISO week number for a given date. + * @param date - The date to calculate the week number for. + * @returns The ISO week number. + */ export const getWeekNumber = date => { - const week1 = new Date(date.getFullYear(), 0, 4) - return ( - 1 + - Math.round( - // eslint-disable-next-line no-mixed-operators - ((date.getTime() - week1.getTime()) / 86_400_000 - 3 + ((week1.getDay() + 6) % 7)) / 7 - ) - ) + const tempDate = new Date(date.getTime()) + tempDate.setHours(0, 0, 0, 0) + + // Thursday in current week decides the year + tempDate.setDate(tempDate.getDate() + 3 - ((tempDate.getDay() + 6) % 7)) + + const week1 = new Date(tempDate.getFullYear(), 0, 4) + + // Calculate full weeks to the date + const weekNumber = + 1 + Math.round((tempDate.getTime() - week1.getTime()) / 86_400_000 / 7) + + return weekNumber } +/** + * Retrieves detailed information about each week in a month for calendar rendering. + * @param year - The year. + * @param month - The month (0-11). + * @param firstDayOfWeek - The first day of the week (0-6, where 0 is Sunday). + * @returns An array of week objects containing week numbers and day details. + */ export const getMonthDetails = (year, month, firstDayOfWeek) => { const daysPrevMonth = getLeadingDays(year, month, firstDayOfWeek) const daysThisMonth = getMonthDays(year, month) @@ -241,26 +319,14 @@ export const getMonthDetails = (year, month, firstDayOfWeek) => { return weeks } -export const isDisableDateInRange = (startDate, endDate, disabledDates) => { - if (startDate && endDate) { - const date = new Date(startDate) - let disabled = false - - // eslint-disable-next-line no-unmodified-loop-condition - while (date < endDate) { - date.setDate(date.getDate() + 1) - if (isDateDisabled(date, null, null, disabledDates)) { - disabled = true - break - } - } - - return disabled - } - - return false -} - +/** + * Checks if a date is disabled based on the 'date' period type. + * @param date - The date to check. + * @param min - Minimum allowed date. + * @param max - Maximum allowed date. + * @param disabledDates - Criteria for disabled dates. + * @returns True if the date is disabled, false otherwise. + */ export const isDateDisabled = (date, min, max, disabledDates) => { if (min && date < min) { return true @@ -270,6 +336,10 @@ export const isDateDisabled = (date, min, max, disabledDates) => { return true } + if (disabledDates === undefined) { + return false + } + if (typeof disabledDates === 'function') { return disabledDates(date) } @@ -297,32 +367,160 @@ export const isDateDisabled = (date, min, max, disabledDates) => { return false } +/** + * Checks if a date is within a specified range. + * @param date - The date to check. + * @param start - Start date of the range. + * @param end - End date of the range. + * @returns True if the date is within the range, false otherwise. + */ export const isDateInRange = (date, start, end) => { const _date = removeTimeFromDate(date) const _start = start ? removeTimeFromDate(start) : null const _end = end ? removeTimeFromDate(end) : null - return _start && _end && _start <= _date && _date <= _end + return Boolean(_start && _end && _start <= _date && _date <= _end) } +/** + * Checks if a date is selected based on start and end dates. + * @param date - The date to check. + * @param start - Start date. + * @param end - End date. + * @returns True if the date is selected, false otherwise. + */ export const isDateSelected = (date, start, end) => { - return ( - (start && isSameDateAs(start, date)) || (end && isSameDateAs(end, date)) - ) + if (start !== null && isSameDateAs(start, date)) { + return true + } + + if (end !== null && isSameDateAs(end, date)) { + return true + } + + return false } -export const isEndDate = (date, start, end) => { - return start && end && isSameDateAs(end, date) && start < end +/** + * Determines if any date within a range is disabled. + * @param startDate - Start date of the range. + * @param endDate - End date of the range. + * @param disabledDates - Criteria for disabled dates. + * @returns True if any date in the range is disabled, false otherwise. + */ +export const isDisableDateInRange = (startDate, endDate, disabledDates) => { + if (startDate && endDate) { + const date = new Date(startDate) + let disabled = false + + // eslint-disable-next-line no-unmodified-loop-condition + while (date < endDate) { + date.setDate(date.getDate() + 1) + if (isDateDisabled(date, null, null, disabledDates)) { + disabled = true + break + } + } + + return disabled + } + + return false } -export const isLastDayOfMonth = date => { - const test = new Date(date.getTime()) - const month = test.getMonth() +/** + * Checks if a month is disabled based on the 'month' period type. + * @param date - The date representing the month to check. + * @param min - Minimum allowed date. + * @param max - Maximum allowed date. + * @param disabledDates - Criteria for disabled dates. + * @returns True if the month is disabled, false otherwise. + */ +export const isMonthDisabled = (date, min, max, disabledDates) => { + const current = (date.getFullYear() * 12) + date.getMonth() + const _min = min ? (min.getFullYear() * 12) + min.getMonth() : null + const _max = max ? (max.getFullYear() * 12) + max.getMonth() : null + + if (_min && current < _min) { + return true + } + + if (_max && current > _max) { + return true + } + + if (disabledDates === undefined) { + return false + } + + const start = min ? Math.max(date.getTime(), min.getTime()) : date + const end = max ? + Math.min(date.getTime(), max.getTime()) : + new Date(new Date().getFullYear(), 11, 31) + + for ( + const currentDate = new Date(start); + // eslint-disable-next-line no-unmodified-loop-condition + currentDate <= end; + currentDate.setDate(currentDate.getDate() + 1) + ) { + if (!isDateDisabled(currentDate, min, max, disabledDates)) { + return false + } + } + + return false +} + +/** + * Checks if a month is selected based on start and end dates. + * @param date - The date representing the month. + * @param start - Start date. + * @param end - End date. + * @returns True if the month is selected, false otherwise. + */ +export const isMonthSelected = (date, start, end) => { + const year = date.getFullYear() + const month = date.getMonth() + + if ( + start !== null && + year === start.getFullYear() && + month === start.getMonth() + ) { + return true + } + + if (end !== null && year === end.getFullYear() && month === end.getMonth()) { + return true + } + + return false +} - test.setDate(test.getDate() + 1) - return test.getMonth() !== month +/** + * Checks if a month is within a specified range. + * @param date - The date representing the month. + * @param start - Start date. + * @param end - End date. + * @returns True if the month is within the range, false otherwise. + */ +export const isMonthInRange = (date, start, end) => { + const year = date.getFullYear() + const month = date.getMonth() + const _start = start ? (start.getFullYear() * 12) + start.getMonth() : null + const _end = end ? (end.getFullYear() * 12) + end.getMonth() : null + const _date = (year * 12) + month + + return Boolean(_start && _end && _start <= _date && _date <= _end) } +/** + * Checks if two dates are the same calendar date. + * @param date - First date. + * @param date2 - Second date. + * @returns True if both dates are the same, false otherwise. + */ export const isSameDateAs = (date, date2) => { if (date instanceof Date && date2 instanceof Date) { return ( @@ -339,23 +537,103 @@ export const isSameDateAs = (date, date2) => { return false } -export const isStartDate = (date, start, end) => { - return start && end && isSameDateAs(start, date) && start < end -} - +/** + * Checks if a date is today. + * @param date - The date to check. + * @returns True if the date is today, false otherwise. + */ export const isToday = date => { const today = new Date() - return ( - date.getDate() === today.getDate() && - date.getMonth() === today.getMonth() && - date.getFullYear() === today.getFullYear() - ) + return isSameDateAs(date, today) } -export const isValidDate = date => { - const d = new Date(date) - return d instanceof Date && d.getTime() +/** + * Checks if a year is disabled based on the 'year' period type. + * @param date - The date representing the year to check. + * @param min - Minimum allowed date. + * @param max - Maximum allowed date. + * @param disabledDates - Criteria for disabled dates. + * @returns True if the year is disabled, false otherwise. + */ +export const isYearDisabled = (date, min, max, disabledDates) => { + const year = date.getFullYear() + const minYear = min ? min.getFullYear() : null + const maxYear = max ? max.getFullYear() : null + + if (minYear && year < minYear) { + return true + } + + if (maxYear && year > maxYear) { + return true + } + + if (disabledDates === undefined) { + return false + } + + const start = min ? Math.max(date.getTime(), min.getTime()) : date + const end = max ? + Math.min(date.getTime(), max.getTime()) : + new Date(new Date().getFullYear(), 11, 31) + + for ( + const currentDate = new Date(start); + // eslint-disable-next-line no-unmodified-loop-condition + currentDate <= end; + currentDate.setDate(currentDate.getDate() + 1) + ) { + if (!isDateDisabled(currentDate, min, max, disabledDates)) { + return false + } + } + + return false } -export const removeTimeFromDate = date => - new Date(date.getFullYear(), date.getMonth(), date.getDate()) +/** + * Checks if a year is selected based on start and end dates. + * @param date - The date representing the year. + * @param start - Start date. + * @param end - End date. + * @returns True if the year matches the start's or end's year, false otherwise. + */ +export const isYearSelected = (date, start, end) => { + const year = date.getFullYear() + + if (start !== null && year === start.getFullYear()) { + return true + } + + if (end !== null && year === end.getFullYear()) { + return true + } + + return false +} + +/** + * Checks if a year is within a specified range. + * @param date - The date representing the year. + * @param start - Start date. + * @param end - End date. + * @returns True if the year's value lies between start's year and end's year, false otherwise. + */ +export const isYearInRange = (date, start, end) => { + const year = date.getFullYear() + const _start = start ? start.getFullYear() : null + const _end = end ? end.getFullYear() : null + + return Boolean(_start && _end && _start <= year && year <= _end) +} + +/** + * Removes the time component from a Date object. + * @param date - The original date. + * @returns A new Date object with the time set to 00:00:00. + */ +export const removeTimeFromDate = date => { + const clearedDate = new Date(date) + clearedDate.setHours(0, 0, 0, 0) + return clearedDate +} diff --git a/js/src/util/date-range-picker.js b/js/src/util/date-range-picker.js new file mode 100644 index 000000000..31bc56b76 --- /dev/null +++ b/js/src/util/date-range-picker.js @@ -0,0 +1,32 @@ +export const getLocalDateFromString = (string, locale, time) => { + const date = new Date(2013, 11, 31, 17, 19, 22) + let regex = time ? date.toLocaleString(locale) : date.toLocaleDateString(locale) + regex = regex + .replace('2013', '(?[0-9]{2,4})') + .replace('12', '(?[0-9]{1,2})') + .replace('31', '(?[0-9]{1,2})') + if (time) { + regex = regex + .replace('5', '(?[0-9]{1,2})') + .replace('17', '(?[0-9]{1,2})') + .replace('19', '(?[0-9]{1,2})') + .replace('22', '(?[0-9]{1,2})') + .replace('PM', '(?[A-Z]{2})') + } + + const rgx = new RegExp(`${regex}`) + const partials = string.match(rgx) + if (partials === null) { + return + } + + const newDate = partials.groups && + (time ? + new Date(Number(partials.groups.year, 10), Number(partials.groups.month, 10) - 1, Number(partials.groups.day), partials.groups.ampm ? + (partials.groups.ampm === 'PM' ? + Number(partials.groups.hour) + 12 : + Number(partials.groups.hour)) : + Number(partials.groups.hour), Number(partials.groups.minute), Number(partials.groups.second)) : + new Date(Number(partials.groups.year), Number(partials.groups.month) - 1, Number(partials.groups.day))) + return newDate +}