/** * USWDS Date Picker Behavior * * Mirrors official USWDS date picker JavaScript behavior exactly * * @uswds-source https://github.com/uswds/uswds/blob/develop/packages/usa-date-picker/src/index.js * @uswds-version 3.8.0 * @last-synced 2025-10-05 * @sync-status ✅ UP TO DATE * * CRITICAL: This file replicates USWDS source code to maintain 100% behavior parity. * DO NOT add custom logic. ALL changes must come from USWDS source updates. */ import { selectOrMatches } from '../../utils/select-or-matches.js'; import { Sanitizer } from '../../utils/sanitizer.js'; /** * Constants from USWDS * * SOURCE: index.js (Lines 1-127) */ const PREFIX = 'usa'; const DATE_PICKER_CLASS = `${PREFIX}-date-picker`; const DATE_PICKER_WRAPPER_CLASS = `${DATE_PICKER_CLASS}__wrapper`; const DATE_PICKER_INITIALIZED_CLASS = `${DATE_PICKER_CLASS}--initialized`; const DATE_PICKER_ACTIVE_CLASS = `${DATE_PICKER_CLASS}--active`; const DATE_PICKER_INTERNAL_INPUT_CLASS = `${DATE_PICKER_CLASS}__internal-input`; const DATE_PICKER_EXTERNAL_INPUT_CLASS = `${DATE_PICKER_CLASS}__external-input`; const DATE_PICKER_BUTTON_CLASS = `${DATE_PICKER_CLASS}__button`; const DATE_PICKER_CALENDAR_CLASS = `${DATE_PICKER_CLASS}__calendar`; const DATE_PICKER_STATUS_CLASS = `${DATE_PICKER_CLASS}__status`; const CALENDAR_DATE_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__date`; const CALENDAR_DATE_FOCUSED_CLASS = `${CALENDAR_DATE_CLASS}--focused`; const CALENDAR_DATE_SELECTED_CLASS = `${CALENDAR_DATE_CLASS}--selected`; const CALENDAR_DATE_PREVIOUS_MONTH_CLASS = `${CALENDAR_DATE_CLASS}--previous-month`; const CALENDAR_DATE_CURRENT_MONTH_CLASS = `${CALENDAR_DATE_CLASS}--current-month`; const CALENDAR_DATE_NEXT_MONTH_CLASS = `${CALENDAR_DATE_CLASS}--next-month`; const CALENDAR_DATE_RANGE_DATE_CLASS = `${CALENDAR_DATE_CLASS}--range-date`; const CALENDAR_DATE_TODAY_CLASS = `${CALENDAR_DATE_CLASS}--today`; const CALENDAR_DATE_RANGE_DATE_START_CLASS = `${CALENDAR_DATE_CLASS}--range-date-start`; const CALENDAR_DATE_RANGE_DATE_END_CLASS = `${CALENDAR_DATE_CLASS}--range-date-end`; const CALENDAR_DATE_WITHIN_RANGE_CLASS = `${CALENDAR_DATE_CLASS}--within-range`; const CALENDAR_PREVIOUS_YEAR_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__previous-year`; const CALENDAR_PREVIOUS_MONTH_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__previous-month`; const CALENDAR_NEXT_YEAR_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__next-year`; const CALENDAR_NEXT_MONTH_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__next-month`; const CALENDAR_MONTH_SELECTION_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__month-selection`; const CALENDAR_YEAR_SELECTION_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__year-selection`; const CALENDAR_MONTH_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__month`; const CALENDAR_MONTH_FOCUSED_CLASS = `${CALENDAR_MONTH_CLASS}--focused`; const CALENDAR_MONTH_SELECTED_CLASS = `${CALENDAR_MONTH_CLASS}--selected`; const CALENDAR_YEAR_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__year`; const CALENDAR_YEAR_FOCUSED_CLASS = `${CALENDAR_YEAR_CLASS}--focused`; const CALENDAR_YEAR_SELECTED_CLASS = `${CALENDAR_YEAR_CLASS}--selected`; const CALENDAR_PREVIOUS_YEAR_CHUNK_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__previous-year-chunk`; const CALENDAR_NEXT_YEAR_CHUNK_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__next-year-chunk`; const CALENDAR_DATE_PICKER_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__date-picker`; const CALENDAR_MONTH_PICKER_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__month-picker`; const CALENDAR_YEAR_PICKER_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__year-picker`; const CALENDAR_TABLE_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__table`; const CALENDAR_ROW_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__row`; const CALENDAR_CELL_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__cell`; const CALENDAR_CELL_CENTER_ITEMS_CLASS = `${CALENDAR_CELL_CLASS}--center-items`; const CALENDAR_MONTH_LABEL_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__month-label`; const CALENDAR_DAY_OF_WEEK_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__day-of-week`; const DATE_PICKER = `.${DATE_PICKER_CLASS}`; const DATE_PICKER_BUTTON = `.${DATE_PICKER_BUTTON_CLASS}`; const DATE_PICKER_INTERNAL_INPUT = `.${DATE_PICKER_INTERNAL_INPUT_CLASS}`; const DATE_PICKER_EXTERNAL_INPUT = `.${DATE_PICKER_EXTERNAL_INPUT_CLASS}`; const DATE_PICKER_CALENDAR = `.${DATE_PICKER_CALENDAR_CLASS}`; const DATE_PICKER_STATUS = `.${DATE_PICKER_STATUS_CLASS}`; const CALENDAR_DATE = `.${CALENDAR_DATE_CLASS}`; const CALENDAR_DATE_FOCUSED = `.${CALENDAR_DATE_FOCUSED_CLASS}`; const CALENDAR_DATE_CURRENT_MONTH = `.${CALENDAR_DATE_CURRENT_MONTH_CLASS}`; const CALENDAR_PREVIOUS_YEAR = `.${CALENDAR_PREVIOUS_YEAR_CLASS}`; const CALENDAR_PREVIOUS_MONTH = `.${CALENDAR_PREVIOUS_MONTH_CLASS}`; const CALENDAR_NEXT_YEAR = `.${CALENDAR_NEXT_YEAR_CLASS}`; const CALENDAR_NEXT_MONTH = `.${CALENDAR_NEXT_MONTH_CLASS}`; const CALENDAR_YEAR_SELECTION = `.${CALENDAR_YEAR_SELECTION_CLASS}`; const CALENDAR_MONTH_SELECTION = `.${CALENDAR_MONTH_SELECTION_CLASS}`; const CALENDAR_MONTH = `.${CALENDAR_MONTH_CLASS}`; const CALENDAR_YEAR = `.${CALENDAR_YEAR_CLASS}`; const CALENDAR_PREVIOUS_YEAR_CHUNK = `.${CALENDAR_PREVIOUS_YEAR_CHUNK_CLASS}`; const CALENDAR_NEXT_YEAR_CHUNK = `.${CALENDAR_NEXT_YEAR_CHUNK_CLASS}`; const CALENDAR_DATE_PICKER = `.${CALENDAR_DATE_PICKER_CLASS}`; const CALENDAR_MONTH_PICKER = `.${CALENDAR_MONTH_PICKER_CLASS}`; const CALENDAR_YEAR_PICKER = `.${CALENDAR_YEAR_PICKER_CLASS}`; const CALENDAR_MONTH_FOCUSED = `.${CALENDAR_MONTH_FOCUSED_CLASS}`; const CALENDAR_YEAR_FOCUSED = `.${CALENDAR_YEAR_FOCUSED_CLASS}`; const VALIDATION_MESSAGE = 'Please enter a valid date'; // An array of Dates that represent each month in the year const MONTH_DATE_SEED = Array.from({ length: 12 }).map((_, i) => new Date(0, i)); // An array of Dates that represent each day of the week const DAY_OF_WEEK_DATE_SEED = Array.from({ length: 7 }).map((_, i) => new Date(0, 0, i)); const CALENDAR_LABELS_BY_LANG = new Map(); const ENTER_KEYCODE = 13; const YEAR_CHUNK = 12; const DEFAULT_MIN_DATE = '0000-01-01'; const DEFAULT_EXTERNAL_DATE_FORMAT = 'MM/DD/YYYY'; const INTERNAL_DATE_FORMAT = 'YYYY-MM-DD'; const NOT_DISABLED_SELECTOR = ':not([disabled])'; /** * TypeScript Interfaces */ interface CalendarLabels { monthLabels: string[]; dayOfWeeklabels: string[]; dayOfWeeksAbv: string[]; } interface DateRangeContext { rangeStartDate: Date | undefined; rangeEndDate: Date | undefined; withinRangeStartDate: Date | undefined; withinRangeEndDate: Date | undefined; } interface DatePickerContext { calendarEl: HTMLDivElement; datePickerEl: HTMLElement; internalInputEl: HTMLInputElement; externalInputEl: HTMLInputElement; statusEl: HTMLDivElement; toggleBtnEl: HTMLButtonElement; firstYearChunkEl: HTMLElement | null; calendarDate: Date | undefined; minDate: Date | undefined; maxDate: Date | undefined; selectedDate: Date | undefined; rangeDate: Date | undefined; defaultDate: Date | undefined; inputDate: Date | undefined; monthLabels: string[]; dayOfWeeklabels: string[]; dayOfWeeksAbv: string[]; } const processFocusableSelectors = (...selectors: string[]) => selectors.map((query) => query + NOT_DISABLED_SELECTOR).join(', '); const DATE_PICKER_FOCUSABLE = processFocusableSelectors( CALENDAR_PREVIOUS_YEAR, CALENDAR_PREVIOUS_MONTH, CALENDAR_YEAR_SELECTION, CALENDAR_MONTH_SELECTION, CALENDAR_NEXT_YEAR, CALENDAR_NEXT_MONTH, CALENDAR_DATE_FOCUSED ); const MONTH_PICKER_FOCUSABLE = processFocusableSelectors(CALENDAR_MONTH_FOCUSED); const YEAR_PICKER_FOCUSABLE = processFocusableSelectors( CALENDAR_PREVIOUS_YEAR_CHUNK, CALENDAR_NEXT_YEAR_CHUNK, CALENDAR_YEAR_FOCUSED ); /** * Date Manipulation Functions * * SOURCE: index.js (Lines 128-583) */ /** * Keep date within month. Month would only be over by 1 to 3 days * * SOURCE: index.js (Lines 137-143) * * @param dateToCheck - The date object to check * @param month - The correct month * @returns The date, corrected if needed */ const keepDateWithinMonth = (dateToCheck: Date, month: number): Date => { if (month !== dateToCheck.getMonth()) { dateToCheck.setDate(0); } return dateToCheck; }; /** * Set date from month day year * * SOURCE: index.js (Lines 153-157) * * @param year - The year to set * @param month - The month to set (zero-indexed) * @param date - The date to set * @returns The set date */ const setDate = (year: number, month: number, date: number): Date => { const newDate = new Date(0); newDate.setFullYear(year, month, date); return newDate; }; /** * Todays date * * SOURCE: index.js (Lines 164-170) * * @returns Todays date */ const today = (): Date => { const newDate = new Date(); const day = newDate.getDate(); const month = newDate.getMonth(); const year = newDate.getFullYear(); return setDate(year, month, day); }; /** * Set date to first day of the month * * SOURCE: index.js (Lines 178-182) * * @param date - The date to adjust * @returns The adjusted date */ const startOfMonth = (date: Date): Date => { const newDate = new Date(0); newDate.setFullYear(date.getFullYear(), date.getMonth(), 1); return newDate; }; /** * Set date to last day of the month * * SOURCE: index.js (Lines 190-194) * * @param date - The date to adjust * @returns The adjusted date */ const lastDayOfMonth = (date: Date): Date => { const newDate = new Date(0); newDate.setFullYear(date.getFullYear(), date.getMonth() + 1, 0); return newDate; }; /** * Add days to date * * SOURCE: index.js (Lines 203-207) * * @param _date - The date to adjust * @param numDays - The difference in days * @returns The adjusted date */ const addDays = (_date: Date, numDays: number): Date => { const newDate = new Date(_date.getTime()); newDate.setDate(newDate.getDate() + numDays); return newDate; }; /** * Subtract days from date * * SOURCE: index.js (Lines 216) * * @param _date - The date to adjust * @param numDays - The difference in days * @returns The adjusted date */ const subDays = (_date: Date, numDays: number): Date => addDays(_date, -numDays); /** * Add weeks to date * * SOURCE: index.js (Lines 225) * * @param _date - The date to adjust * @param numWeeks - The difference in weeks * @returns The adjusted date */ const addWeeks = (_date: Date, numWeeks: number): Date => addDays(_date, numWeeks * 7); /** * Subtract weeks from date * * SOURCE: index.js (Lines 234) * * @param _date - The date to adjust * @param numWeeks - The difference in weeks * @returns The adjusted date */ const subWeeks = (_date: Date, numWeeks: number): Date => addWeeks(_date, -numWeeks); /** * Set date to the start of the week (Sunday) * * SOURCE: index.js (Lines 242-245) * * @param _date - The date to adjust * @returns The adjusted date */ const startOfWeek = (_date: Date): Date => { const dayOfWeek = _date.getDay(); return subDays(_date, dayOfWeek); }; /** * Set date to the end of the week (Saturday) * * SOURCE: index.js (Lines 254-257) * * @param _date - The date to adjust * @returns The adjusted date */ const endOfWeek = (_date: Date): Date => { const dayOfWeek = _date.getDay(); return addDays(_date, 6 - dayOfWeek); }; /** * Add months to date and keep date within month * * SOURCE: index.js (Lines 266-274) * * @param _date - The date to adjust * @param numMonths - The difference in months * @returns The adjusted date */ const addMonths = (_date: Date, numMonths: number): Date => { const newDate = new Date(_date.getTime()); const dateMonth = (newDate.getMonth() + 12 + numMonths) % 12; newDate.setMonth(newDate.getMonth() + numMonths); keepDateWithinMonth(newDate, dateMonth); return newDate; }; /** * Subtract months from date * * SOURCE: index.js (Lines 283) * * @param _date - The date to adjust * @param numMonths - The difference in months * @returns The adjusted date */ const subMonths = (_date: Date, numMonths: number): Date => addMonths(_date, -numMonths); /** * Add years to date and keep date within month * * SOURCE: index.js (Lines 292) * * @param _date - The date to adjust * @param numYears - The difference in years * @returns The adjusted date */ const addYears = (_date: Date, numYears: number): Date => addMonths(_date, numYears * 12); /** * Subtract years from date * * SOURCE: index.js (Lines 301) * * @param _date - The date to adjust * @param numYears - The difference in years * @returns The adjusted date */ const subYears = (_date: Date, numYears: number): Date => addYears(_date, -numYears); /** * Set months of date * * SOURCE: index.js (Lines 310-317) * * @param _date - The date to adjust * @param month - Zero-indexed month to set * @returns The adjusted date */ const setMonth = (_date: Date, month: number): Date => { const newDate = new Date(_date.getTime()); newDate.setMonth(month); keepDateWithinMonth(newDate, month); return newDate; }; /** * Set year of date * * SOURCE: index.js (Lines 326-334) * * @param _date - The date to adjust * @param year - The year to set * @returns The adjusted date */ const setYear = (_date: Date, year: number): Date => { const newDate = new Date(_date.getTime()); const month = newDate.getMonth(); newDate.setFullYear(year); keepDateWithinMonth(newDate, month); return newDate; }; /** * Return the earliest date * * SOURCE: index.js (Lines 343-351) * * @param dateA - Date to compare * @param dateB - Date to compare * @returns The earliest date */ const min = (dateA: Date, dateB: Date): Date => { let newDate = dateA; if (dateB < dateA) { newDate = dateB; } return new Date(newDate.getTime()); }; /** * Return the latest date * * SOURCE: index.js (Lines 360-368) * * @param dateA - Date to compare * @param dateB - Date to compare * @returns The latest date */ const max = (dateA: Date, dateB: Date): Date => { let newDate = dateA; if (dateB > dateA) { newDate = dateB; } return new Date(newDate.getTime()); }; /** * Check if dates are the in the same year * * SOURCE: index.js (Lines 377-378) * * @param dateA - Date to compare * @param dateB - Date to compare * @returns Are dates in the same year */ const isSameYear = (dateA: Date | undefined, dateB: Date | undefined): boolean => !!dateA && !!dateB && dateA.getFullYear() === dateB.getFullYear(); /** * Check if dates are the in the same month * * SOURCE: index.js (Lines 387-388) * * @param dateA - Date to compare * @param dateB - Date to compare * @returns Are dates in the same month */ const isSameMonth = (dateA: Date | undefined, dateB: Date | undefined): boolean => isSameYear(dateA, dateB) && dateA!.getMonth() === dateB!.getMonth(); /** * Check if dates are the same date * * SOURCE: index.js (Lines 397-398) * * @param dateA - The date to compare * @param dateB - The date to compare * @returns Are dates the same date */ const isSameDay = (dateA: Date | undefined, dateB: Date | undefined): boolean => isSameMonth(dateA, dateB) && dateA!.getDate() === dateB!.getDate(); /** * Return a new date within minimum and maximum date * * SOURCE: index.js (Lines 408-418) * * @param date - Date to check * @param minDate - Minimum date to allow * @param maxDate - Maximum date to allow * @returns The date between min and max */ const keepDateBetweenMinAndMax = ( date: Date, minDate: Date, maxDate: Date | undefined ): Date => { let newDate = date; if (date < minDate) { newDate = minDate; } else if (maxDate && date > maxDate) { newDate = maxDate; } return new Date(newDate.getTime()); }; /** * Check if dates is valid. * * SOURCE: index.js (Lines 428-429) * * @param date - Date to check * @param minDate - Minimum date to allow * @param maxDate - Maximum date to allow * @return Is there a day within the month within min and max dates */ const isDateWithinMinAndMax = ( date: Date, minDate: Date, maxDate: Date | undefined ): boolean => date >= minDate && (!maxDate || date <= maxDate); /** * Check if dates month is invalid. * * SOURCE: index.js (Lines 439-440) * * @param date - Date to check * @param minDate - Minimum date to allow * @param maxDate - Maximum date to allow * @return Is the month outside min or max dates */ const isDatesMonthOutsideMinOrMax = ( date: Date, minDate: Date, maxDate: Date | undefined ): boolean => lastDayOfMonth(date) < minDate || (!!maxDate && startOfMonth(date) > maxDate); /** * Check if dates year is invalid. * * SOURCE: index.js (Lines 450-452) * * @param date - Date to check * @param minDate - Minimum date to allow * @param maxDate - Maximum date to allow * @return Is the month outside min or max dates */ const isDatesYearOutsideMinOrMax = ( date: Date, minDate: Date, maxDate: Date | undefined ): boolean => lastDayOfMonth(setMonth(date, 11)) < minDate || (!!maxDate && startOfMonth(setMonth(date, 0)) > maxDate); /** * Set the start, end, and within range values for date range variants. * * SOURCE: index.js (Lines 469-483) * * @param date - Date that concludes the date range. * @param rangeDate - Range date data attribute value of the date picker component. * @returns Dates for range selection. */ const setRangeDates = (date: Date, rangeDate: Date | undefined): DateRangeContext => { const rangeConclusionDate = date; const rangeStartDate = rangeDate ? min(rangeConclusionDate, rangeDate) : undefined; const rangeEndDate = rangeDate ? max(rangeConclusionDate, rangeDate) : undefined; const withinRangeStartDate = rangeDate ? addDays(rangeStartDate!, 1) : undefined; const withinRangeEndDate = rangeDate ? subDays(rangeEndDate!, 1) : undefined; return { rangeStartDate, rangeEndDate, withinRangeStartDate, withinRangeEndDate, }; }; /** * Parse a date with format M-D-YY * * SOURCE: index.js (Lines 493-560) * * @param dateString - The date string to parse * @param dateFormat - The format of the date string * @param adjustDate - Should the date be adjusted * @returns The parsed date */ const parseDateString = ( dateString: string | undefined, dateFormat: string = INTERNAL_DATE_FORMAT, adjustDate: boolean = false ): Date | undefined => { let date; let month; let day; let year; let parsed; if (dateString) { let monthStr; let dayStr; let yearStr; if (dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT) { [monthStr, dayStr, yearStr] = dateString.split('/'); } else { [yearStr, monthStr, dayStr] = dateString.split('-'); } if (yearStr) { parsed = parseInt(yearStr, 10); if (!Number.isNaN(parsed)) { year = parsed; if (adjustDate) { year = Math.max(0, year); if (yearStr.length < 3) { const currentYear = today().getFullYear(); const currentYearStub = currentYear - (currentYear % 10 ** yearStr.length); year = currentYearStub + parsed; } } } } if (monthStr) { parsed = parseInt(monthStr, 10); if (!Number.isNaN(parsed)) { month = parsed; if (adjustDate) { month = Math.max(1, month); month = Math.min(12, month); } } } if (month && dayStr && year != null) { parsed = parseInt(dayStr, 10); if (!Number.isNaN(parsed)) { day = parsed; if (adjustDate) { const lastDayOfTheMonth = setDate(year, month, 0).getDate(); day = Math.max(1, day); day = Math.min(lastDayOfTheMonth, day); } } } if (month && day && year != null) { date = setDate(year, month - 1, day); } } return date; }; /** * Format a date to format MM-DD-YYYY * * SOURCE: index.js (Lines 569-581) * * @param date - The date to format * @param dateFormat - The format of the date string * @returns The formatted date string */ const formatDate = (date: Date, dateFormat: string = INTERNAL_DATE_FORMAT): string => { const padZeros = (value: number, length: number) => `0000${value}`.slice(-length); const month = date.getMonth() + 1; const day = date.getDate(); const year = date.getFullYear(); if (dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT) { return [padZeros(month, 2), padZeros(day, 2), padZeros(year, 4)].join('/'); } return [padZeros(year, 4), padZeros(month, 2), padZeros(day, 2)].join('-'); }; /** * Utility Functions * * SOURCE: index.js (Lines 592-643) */ /** * Create a grid string from an array of html elements * * SOURCE: index.js (Lines 592-616) * * @param htmlArray - The array of html items * @param rowSize - The length of a row * @returns The grid array */ const listToGridHtml = (htmlArray: HTMLElement[], rowSize: number): HTMLTableRowElement[] => { const grid: HTMLTableRowElement[] = []; let row: HTMLTableCellElement[] = []; let i = 0; while (i < htmlArray.length) { row = []; const tr = document.createElement('tr'); while (i < htmlArray.length && row.length < rowSize) { const td = document.createElement('td'); td.insertAdjacentElement('beforeend', htmlArray[i]); row.push(td); i += 1; } row.forEach((element) => { tr.insertAdjacentElement('beforeend', element); }); grid.push(tr); } return grid; }; /** * Create table body from grid * * SOURCE: index.js (Lines 618-625) */ const createTableBody = (grid: HTMLTableRowElement[]): HTMLTableSectionElement => { const tableBody = document.createElement('tbody'); grid.forEach((element) => { tableBody.insertAdjacentElement('beforeend', element); }); return tableBody; }; /** * Set the value of the element and dispatch a change event * * SOURCE: index.js (Lines 633-643) * * @param el - The element to update * @param value - The new value of the element */ const changeElementValue = (el: HTMLInputElement, value: string = ''): void => { const elementToChange = el; elementToChange.value = value; const event = new CustomEvent('change', { bubbles: true, cancelable: true, detail: { value }, }); elementToChange.dispatchEvent(event); }; /** * Get an object of the properties and elements belonging directly to the given * date picker component. * * SOURCE: index.js (Lines 669-747) * * @param el - The element within the date picker * @returns Elements and context */ const getDatePickerContext = (el: HTMLElement): DatePickerContext => { const datePickerEl = el.closest(DATE_PICKER) as HTMLElement; if (!datePickerEl) { throw new Error(`Element is missing outer ${DATE_PICKER}`); } const internalInputEl = datePickerEl.querySelector( DATE_PICKER_INTERNAL_INPUT ) as HTMLInputElement; const externalInputEl = datePickerEl.querySelector( DATE_PICKER_EXTERNAL_INPUT ) as HTMLInputElement; const calendarEl = datePickerEl.querySelector(DATE_PICKER_CALENDAR) as HTMLDivElement; const toggleBtnEl = datePickerEl.querySelector(DATE_PICKER_BUTTON) as HTMLButtonElement; const statusEl = datePickerEl.querySelector(DATE_PICKER_STATUS) as HTMLDivElement; const firstYearChunkEl = datePickerEl.querySelector(CALENDAR_YEAR) as HTMLElement | null; const inputDate = parseDateString( externalInputEl.value, DEFAULT_EXTERNAL_DATE_FORMAT, true ); const selectedDate = parseDateString(internalInputEl.value); const calendarDate = parseDateString(calendarEl.dataset.value); const minDate = parseDateString(datePickerEl.dataset.minDate); const maxDate = parseDateString(datePickerEl.dataset.maxDate); const rangeDate = parseDateString(datePickerEl.dataset.rangeDate); const defaultDate = parseDateString(datePickerEl.dataset.defaultDate); if (minDate && maxDate && minDate > maxDate) { throw new Error('Minimum date cannot be after maximum date'); } const lang = document.documentElement.lang || 'en'; // if the language is not found generate the list if (!CALENDAR_LABELS_BY_LANG.has(lang)) { CALENDAR_LABELS_BY_LANG.set(lang, { monthLabels: MONTH_DATE_SEED.map((date) => date.toLocaleString(lang, { month: 'long' })), dayOfWeeklabels: DAY_OF_WEEK_DATE_SEED.map((date) => date.toLocaleString(lang, { weekday: 'long', }) ), dayOfWeeksAbv: DAY_OF_WEEK_DATE_SEED.map((date) => date.toLocaleString(lang, { weekday: 'narrow', }) ), }); } const { monthLabels, dayOfWeeklabels, dayOfWeeksAbv } = CALENDAR_LABELS_BY_LANG.get(lang)!; return { calendarDate, minDate, toggleBtnEl, selectedDate, maxDate, firstYearChunkEl, datePickerEl, inputDate, internalInputEl, externalInputEl, calendarEl, rangeDate, defaultDate, statusEl, monthLabels, dayOfWeeklabels, dayOfWeeksAbv, }; }; /** * Disable the date picker component * * SOURCE: index.js (Lines 754-759) * * @param el - An element within the date picker component */ const disable = (el: HTMLElement): void => { const { externalInputEl, toggleBtnEl } = getDatePickerContext(el); toggleBtnEl.disabled = true; externalInputEl.disabled = true; }; /** * Add the readonly attribute to input element and the aria-disabled attribute * * SOURCE: index.js (Lines 766-772) * * @param el - The date picker element */ const ariaDisable = (el: HTMLElement): void => { const { externalInputEl, toggleBtnEl } = getDatePickerContext(el); toggleBtnEl.setAttribute('aria-disabled', 'true'); externalInputEl.setAttribute('aria-disabled', 'true'); externalInputEl.setAttribute('readonly', ''); }; /** * Enable the date picker component * * SOURCE: index.js (Lines 779-788) * * @param el - An element within the date picker component * * Note: This function mirrors USWDS source but is not currently used. * Kept for USWDS alignment and potential future use. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars const enable = (el: HTMLElement): void => { const { externalInputEl, toggleBtnEl } = getDatePickerContext(el); toggleBtnEl.disabled = false; toggleBtnEl.removeAttribute('aria-disabled'); externalInputEl.disabled = false; externalInputEl.removeAttribute('aria-disabled'); externalInputEl.removeAttribute('readonly'); }; // Prevent unused warning while maintaining USWDS mirror void enable; /** * Validation Functions * * SOURCE: index.js (Lines 797-868) */ /** * Validate the value in the input as a valid date of format M/D/YYYY * * SOURCE: index.js (Lines 797-830) * * @param el - An element within the date picker component */ const isDateInputInvalid = (el: HTMLElement): boolean => { const { externalInputEl, minDate, maxDate } = getDatePickerContext(el); const dateString = externalInputEl.value; let isInvalid = false; if (dateString) { isInvalid = true; const dateStringParts = dateString.split('/'); const [month, day, year] = dateStringParts.map((str) => { let value: number | undefined; const parsed = parseInt(str, 10); if (!Number.isNaN(parsed)) value = parsed; return value; }); if (month && day && year != null) { const checkDate = setDate(year, month - 1, day); if ( checkDate.getMonth() === month - 1 && checkDate.getDate() === day && checkDate.getFullYear() === year && dateStringParts[2].length === 4 && isDateWithinMinAndMax(checkDate, minDate!, maxDate) ) { isInvalid = false; } } } return isInvalid; }; /** * Validate the value in the input as a valid date of format M/D/YYYY * * SOURCE: index.js (Lines 837-848) * * @param el - An element within the date picker component */ const validateDateInput = (el: HTMLElement): void => { const { externalInputEl } = getDatePickerContext(el); const isInvalid = isDateInputInvalid(externalInputEl); if (isInvalid && !externalInputEl.validationMessage) { externalInputEl.setCustomValidity(VALIDATION_MESSAGE); } if (!isInvalid && externalInputEl.validationMessage === VALIDATION_MESSAGE) { externalInputEl.setCustomValidity(''); } }; /** * Enable the date picker component * * SOURCE: index.js (Lines 857-868) * * @param el - An element within the date picker component */ const reconcileInputValues = (el: HTMLElement): void => { const { internalInputEl, inputDate } = getDatePickerContext(el); let newValue = ''; if (inputDate && !isDateInputInvalid(el)) { newValue = formatDate(inputDate); } if (internalInputEl.value !== newValue) { changeElementValue(internalInputEl, newValue); } }; /** * Select the value of the date picker inputs. * * SOURCE: index.js (Lines 876-890) * * @param el - An element within the date picker component * @param dateString - The date string to update in YYYY-MM-DD format */ const setCalendarValue = (el: HTMLElement, dateString: string): void => { const parsedDate = parseDateString(dateString); if (parsedDate) { const formattedDate = formatDate(parsedDate, DEFAULT_EXTERNAL_DATE_FORMAT); const { datePickerEl, internalInputEl, externalInputEl } = getDatePickerContext(el); changeElementValue(internalInputEl, dateString); changeElementValue(externalInputEl, formattedDate); validateDateInput(datePickerEl); } }; /** * Enhance an input with the date picker elements * * SOURCE: index.js (Lines 897-965) * * @param el - The initial wrapping element of the date picker component */ const enhanceDatePicker = (el: HTMLElement): void => { const datePickerEl = el.closest(DATE_PICKER) as HTMLElement; const { defaultValue } = datePickerEl.dataset; const internalInputEl = datePickerEl.querySelector(`input`) as HTMLInputElement; if (!internalInputEl) { throw new Error(`${DATE_PICKER} is missing inner input`); } if (internalInputEl.value) { internalInputEl.value = ''; } const minDate = parseDateString( datePickerEl.dataset.minDate || internalInputEl.getAttribute('min') || undefined ); datePickerEl.dataset.minDate = minDate ? formatDate(minDate) : DEFAULT_MIN_DATE; const maxDate = parseDateString( datePickerEl.dataset.maxDate || internalInputEl.getAttribute('max') || undefined ); if (maxDate) { datePickerEl.dataset.maxDate = formatDate(maxDate); } const calendarWrapper = document.createElement('div'); calendarWrapper.classList.add(DATE_PICKER_WRAPPER_CLASS); const externalInputEl = internalInputEl.cloneNode() as HTMLInputElement; externalInputEl.classList.add(DATE_PICKER_EXTERNAL_INPUT_CLASS); externalInputEl.type = 'text'; calendarWrapper.appendChild(externalInputEl); calendarWrapper.insertAdjacentHTML( 'beforeend', Sanitizer.escapeHTML`
` ); internalInputEl.setAttribute('aria-hidden', 'true'); internalInputEl.setAttribute('tabindex', '-1'); internalInputEl.style.display = 'none'; internalInputEl.classList.add(DATE_PICKER_INTERNAL_INPUT_CLASS); internalInputEl.removeAttribute('id'); internalInputEl.removeAttribute('name'); internalInputEl.required = false; datePickerEl.appendChild(calendarWrapper); datePickerEl.classList.add(DATE_PICKER_INITIALIZED_CLASS); if (defaultValue) { setCalendarValue(datePickerEl, defaultValue); } if (internalInputEl.disabled) { disable(datePickerEl); internalInputEl.disabled = false; } if (internalInputEl.hasAttribute('aria-disabled')) { ariaDisable(datePickerEl); internalInputEl.removeAttribute('aria-disabled'); } }; /** * Calendar - Date Selection View * * SOURCE: index.js (Lines 976-1371) */ /** * Render the calendar * * SOURCE: index.js (Lines 976-1223) * * @param el - An element within the date picker component * @param _dateToDisplay - A date to render on the calendar * @returns A reference to the new calendar element */ const renderCalendar = (el: HTMLElement, _dateToDisplay: Date | undefined): HTMLElement => { const { datePickerEl, calendarEl, statusEl, selectedDate, maxDate, minDate, rangeDate, monthLabels, dayOfWeeklabels, dayOfWeeksAbv, } = getDatePickerContext(el); const todaysDate = today(); let dateToDisplay = _dateToDisplay || todaysDate; const calendarWasHidden = calendarEl.hidden; const focusedDate = addDays(dateToDisplay, 0); const focusedMonth = dateToDisplay.getMonth(); const focusedYear = dateToDisplay.getFullYear(); const prevMonth = subMonths(dateToDisplay, 1); const nextMonth = addMonths(dateToDisplay, 1); const currentFormattedDate = formatDate(dateToDisplay); const firstOfMonth = startOfMonth(dateToDisplay); const prevButtonsDisabled = isSameMonth(dateToDisplay, minDate); const nextButtonsDisabled = isSameMonth(dateToDisplay, maxDate); const { rangeStartDate, rangeEndDate, withinRangeStartDate, withinRangeEndDate } = setRangeDates(selectedDate || dateToDisplay, rangeDate); const monthLabel = monthLabels[focusedMonth]; const generateDateHtml = (dateToRender: Date): HTMLButtonElement => { const classes = [CALENDAR_DATE_CLASS]; const day = dateToRender.getDate(); const month = dateToRender.getMonth(); const year = dateToRender.getFullYear(); const dayOfWeek = dateToRender.getDay(); const formattedDate = formatDate(dateToRender); let tabindex = '-1'; const isDisabled = !isDateWithinMinAndMax(dateToRender, minDate!, maxDate); const isSelected = isSameDay(dateToRender, selectedDate); if (isSameMonth(dateToRender, prevMonth)) { classes.push(CALENDAR_DATE_PREVIOUS_MONTH_CLASS); } if (isSameMonth(dateToRender, focusedDate)) { classes.push(CALENDAR_DATE_CURRENT_MONTH_CLASS); } if (isSameMonth(dateToRender, nextMonth)) { classes.push(CALENDAR_DATE_NEXT_MONTH_CLASS); } if (isSelected) { classes.push(CALENDAR_DATE_SELECTED_CLASS); } if (isSameDay(dateToRender, todaysDate)) { classes.push(CALENDAR_DATE_TODAY_CLASS); } if (rangeDate) { if (isSameDay(dateToRender, rangeDate)) { classes.push(CALENDAR_DATE_RANGE_DATE_CLASS); } if (isSameDay(dateToRender, rangeStartDate)) { classes.push(CALENDAR_DATE_RANGE_DATE_START_CLASS); } if (isSameDay(dateToRender, rangeEndDate)) { classes.push(CALENDAR_DATE_RANGE_DATE_END_CLASS); } if (isDateWithinMinAndMax(dateToRender, withinRangeStartDate!, withinRangeEndDate)) { classes.push(CALENDAR_DATE_WITHIN_RANGE_CLASS); } } if (isSameDay(dateToRender, focusedDate)) { tabindex = '0'; classes.push(CALENDAR_DATE_FOCUSED_CLASS); } const monthStr = monthLabels[month]; const dayStr = dayOfWeeklabels[dayOfWeek]; const btn = document.createElement('button'); btn.setAttribute('type', 'button'); btn.setAttribute('tabindex', tabindex); btn.setAttribute('class', classes.join(' ')); btn.setAttribute('data-day', String(day)); btn.setAttribute('data-month', String(month + 1)); btn.setAttribute('data-year', String(year)); btn.setAttribute('data-value', formattedDate); btn.setAttribute('aria-label', Sanitizer.escapeHTML`${day} ${monthStr} ${year} ${dayStr}`); btn.setAttribute('aria-selected', isSelected ? 'true' : 'false'); if (isDisabled === true) { btn.disabled = true; } btn.textContent = String(day); return btn; }; // set date to first rendered day dateToDisplay = startOfWeek(firstOfMonth); const days: HTMLElement[] = []; while ( days.length < 28 || dateToDisplay.getMonth() === focusedMonth || days.length % 7 !== 0 ) { days.push(generateDateHtml(dateToDisplay)); dateToDisplay = addDays(dateToDisplay, 1); } const datesGrid = listToGridHtml(days, 7); // CRITICAL: Update calendar in place instead of replacing to preserve element references // This is essential for test contracts that hold references to the calendar element calendarEl.dataset.value = currentFormattedDate; (calendarEl.style as any).top = `${datePickerEl.offsetHeight}px`; calendarEl.hidden = false; calendarEl.removeAttribute('hidden'); // CRITICAL: Must remove attribute for Lit-rendered elements calendarEl.innerHTML = Sanitizer.escapeHTML`
`; const table = document.createElement('table'); table.setAttribute('class', CALENDAR_TABLE_CLASS); const tableHead = document.createElement('thead'); table.insertAdjacentElement('beforeend', tableHead); const tableHeadRow = document.createElement('tr'); tableHead.insertAdjacentElement('beforeend', tableHeadRow); dayOfWeeklabels.forEach((dayOfWeek, i) => { const th = document.createElement('th'); th.setAttribute('class', CALENDAR_DAY_OF_WEEK_CLASS); th.setAttribute('scope', 'col'); th.setAttribute('aria-label', dayOfWeek); th.textContent = dayOfWeeksAbv[i]; tableHeadRow.insertAdjacentElement('beforeend', th); }); const tableBody = createTableBody(datesGrid); table.insertAdjacentElement('beforeend', tableBody); // Container for Years, Months, and Days const datePickerCalendarContainer = calendarEl.querySelector( CALENDAR_DATE_PICKER ) as HTMLElement; datePickerCalendarContainer.insertAdjacentElement('beforeend', table); datePickerEl.classList.add(DATE_PICKER_ACTIVE_CLASS); const statuses: string[] = []; if (isSameDay(selectedDate, focusedDate)) { statuses.push('Selected date'); } if (calendarWasHidden) { statuses.push( 'You can navigate by day using left and right arrows', 'Weeks by using up and down arrows', 'Months by using page up and page down keys', 'Years by using shift plus page up and shift plus page down', 'Home and end keys navigate to the beginning and end of a week' ); statusEl.textContent = ''; } else { statuses.push(`${monthLabel} ${focusedYear}`); } statusEl.textContent = statuses.join('. '); return calendarEl; }; /** * Navigate back one year and display the calendar. * * SOURCE: index.js (Lines 1230-1243) * * @param _buttonEl - An element within the date picker component */ const displayPreviousYear = (_buttonEl: HTMLElement): void => { if ((_buttonEl as HTMLButtonElement).disabled) return; const { calendarEl, calendarDate, minDate, maxDate } = getDatePickerContext(_buttonEl); let date = subYears(calendarDate!, 1); date = keepDateBetweenMinAndMax(date, minDate!, maxDate); const newCalendar = renderCalendar(calendarEl, date); let nextToFocus = newCalendar.querySelector(CALENDAR_PREVIOUS_YEAR) as HTMLElement; if ((nextToFocus as HTMLButtonElement).disabled) { nextToFocus = newCalendar.querySelector(CALENDAR_DATE_PICKER) as HTMLElement; } nextToFocus.focus(); }; /** * Navigate back one month and display the calendar. * * SOURCE: index.js (Lines 1250-1263) * * @param _buttonEl - An element within the date picker component */ const displayPreviousMonth = (_buttonEl: HTMLElement): void => { if ((_buttonEl as HTMLButtonElement).disabled) return; const { calendarEl, calendarDate, minDate, maxDate } = getDatePickerContext(_buttonEl); let date = subMonths(calendarDate!, 1); date = keepDateBetweenMinAndMax(date, minDate!, maxDate); const newCalendar = renderCalendar(calendarEl, date); let nextToFocus = newCalendar.querySelector(CALENDAR_PREVIOUS_MONTH) as HTMLElement; if ((nextToFocus as HTMLButtonElement).disabled) { nextToFocus = newCalendar.querySelector(CALENDAR_DATE_PICKER) as HTMLElement; } nextToFocus.focus(); }; /** * Navigate forward one month and display the calendar. * * SOURCE: index.js (Lines 1270-1283) * * @param _buttonEl - An element within the date picker component */ const displayNextMonth = (_buttonEl: HTMLElement): void => { if ((_buttonEl as HTMLButtonElement).disabled) return; const { calendarEl, calendarDate, minDate, maxDate } = getDatePickerContext(_buttonEl); let date = addMonths(calendarDate!, 1); date = keepDateBetweenMinAndMax(date, minDate!, maxDate); const newCalendar = renderCalendar(calendarEl, date); let nextToFocus = newCalendar.querySelector(CALENDAR_NEXT_MONTH) as HTMLElement; if ((nextToFocus as HTMLButtonElement).disabled) { nextToFocus = newCalendar.querySelector(CALENDAR_DATE_PICKER) as HTMLElement; } nextToFocus.focus(); }; /** * Navigate forward one year and display the calendar. * * SOURCE: index.js (Lines 1290-1303) * * @param _buttonEl - An element within the date picker component */ const displayNextYear = (_buttonEl: HTMLElement): void => { if ((_buttonEl as HTMLButtonElement).disabled) return; const { calendarEl, calendarDate, minDate, maxDate } = getDatePickerContext(_buttonEl); let date = addYears(calendarDate!, 1); date = keepDateBetweenMinAndMax(date, minDate!, maxDate); const newCalendar = renderCalendar(calendarEl, date); let nextToFocus = newCalendar.querySelector(CALENDAR_NEXT_YEAR) as HTMLElement; if ((nextToFocus as HTMLButtonElement).disabled) { nextToFocus = newCalendar.querySelector(CALENDAR_DATE_PICKER) as HTMLElement; } nextToFocus.focus(); }; /** * Hide the calendar of a date picker component. * * SOURCE: index.js (Lines 1310-1316) * * @param el - An element within the date picker component */ const hideCalendar = (el: HTMLElement): void => { const { datePickerEl, calendarEl, statusEl } = getDatePickerContext(el); datePickerEl.classList.remove(DATE_PICKER_ACTIVE_CLASS); calendarEl.hidden = true; calendarEl.setAttribute('hidden', ''); // CRITICAL: Must set attribute for Lit-rendered elements statusEl.textContent = ''; }; /** * Select a date within the date picker component. * * SOURCE: index.js (Lines 1323-1333) * * @param calendarDateEl - A date element within the date picker component */ const selectDate = (calendarDateEl: HTMLElement): void => { if ((calendarDateEl as HTMLButtonElement).disabled) return; const { datePickerEl, externalInputEl } = getDatePickerContext(calendarDateEl); setCalendarValue(calendarDateEl, (calendarDateEl as HTMLElement).dataset.value!); hideCalendar(datePickerEl); externalInputEl.focus(); }; /** * Toggle the calendar. * * SOURCE: index.js (Lines 1340-1356) * * @param el - An element within the date picker component */ const toggleCalendar = (el: HTMLElement): void => { if ( (el as HTMLButtonElement).disabled || el.hasAttribute('aria-disabled') ) return; const { calendarEl, inputDate, minDate, maxDate, defaultDate } = getDatePickerContext(el); if (calendarEl.hidden) { const dateToDisplay = keepDateBetweenMinAndMax( inputDate || defaultDate || today(), minDate!, maxDate ); const newCalendar = renderCalendar(calendarEl, dateToDisplay); (newCalendar.querySelector(CALENDAR_DATE_FOCUSED) as HTMLElement).focus(); } else { hideCalendar(el); } }; /** * Update the calendar when visible. * * SOURCE: index.js (Lines 1363-1371) * * @param el - An element within the date picker */ const updateCalendarIfVisible = (el: HTMLElement): void => { const { calendarEl, inputDate, minDate, maxDate } = getDatePickerContext(el); const calendarShown = !calendarEl.hidden; if (calendarShown && inputDate) { const dateToDisplay = keepDateBetweenMinAndMax(inputDate, minDate!, maxDate); renderCalendar(calendarEl, dateToDisplay); } }; /** * Calendar - Month Selection View * * SOURCE: index.js (Lines 1382-1463) */ /** * Display the month selection screen in the date picker. * * SOURCE: index.js (Lines 1382-1447) * * @param el - An element within the date picker component * @param monthToDisplay - Month to display * @returns A reference to the new calendar element */ const displayMonthSelection = ( el: HTMLElement, monthToDisplay?: number ): HTMLElement => { const { calendarEl, statusEl, calendarDate, minDate, maxDate, monthLabels } = getDatePickerContext(el); const selectedMonth = calendarDate!.getMonth(); const focusedMonth = monthToDisplay == null ? selectedMonth : monthToDisplay; const months = monthLabels.map((month, index) => { const monthToCheck = setMonth(calendarDate!, index); const isDisabled = isDatesMonthOutsideMinOrMax(monthToCheck, minDate!, maxDate); let tabindex = '-1'; const classes = [CALENDAR_MONTH_CLASS]; const isSelected = index === selectedMonth; if (index === focusedMonth) { tabindex = '0'; classes.push(CALENDAR_MONTH_FOCUSED_CLASS); } if (isSelected) { classes.push(CALENDAR_MONTH_SELECTED_CLASS); } const btn = document.createElement('button'); btn.setAttribute('type', 'button'); btn.setAttribute('tabindex', tabindex); btn.setAttribute('class', classes.join(' ')); btn.setAttribute('data-value', String(index)); btn.setAttribute('data-label', month); btn.setAttribute('aria-selected', isSelected ? 'true' : 'false'); if (isDisabled === true) { btn.disabled = true; } btn.textContent = month; return btn; }); const monthsHtml = document.createElement('div'); monthsHtml.setAttribute('tabindex', '-1'); monthsHtml.setAttribute('class', CALENDAR_MONTH_PICKER_CLASS); const table = document.createElement('table'); table.setAttribute('class', CALENDAR_TABLE_CLASS); table.setAttribute('role', 'presentation'); const monthsGrid = listToGridHtml(months, 3); const tableBody = createTableBody(monthsGrid); table.insertAdjacentElement('beforeend', tableBody); monthsHtml.insertAdjacentElement('beforeend', table); // CRITICAL: Update calendar in place instead of replacing to preserve element references calendarEl.innerHTML = ''; calendarEl.insertAdjacentElement('beforeend', monthsHtml); statusEl.textContent = 'Select a month.'; return calendarEl; }; /** * Select a month in the date picker component. * * SOURCE: index.js (Lines 1454-1463) * * @param monthEl - A month element within the date picker component */ const selectMonth = (monthEl: HTMLElement): void => { if ((monthEl as HTMLButtonElement).disabled) return; const { calendarEl, calendarDate, minDate, maxDate } = getDatePickerContext(monthEl); const selectedMonth = parseInt((monthEl as HTMLElement).dataset.value!, 10); let date = setMonth(calendarDate!, selectedMonth); date = keepDateBetweenMinAndMax(date, minDate!, maxDate); const newCalendar = renderCalendar(calendarEl, date); (newCalendar.querySelector(CALENDAR_DATE_FOCUSED) as HTMLElement).focus(); }; /** * Calendar - Year Selection View * * SOURCE: index.js (Lines 1476-1716) */ /** * Display the year selection screen in the date picker. * * SOURCE: index.js (Lines 1476-1640) * * @param el - An element within the date picker component * @param yearToDisplay - Year to display in year selection * @returns A reference to the new calendar element */ const displayYearSelection = (el: HTMLElement, yearToDisplay?: number): HTMLElement => { const { calendarEl, statusEl, calendarDate, minDate, maxDate } = getDatePickerContext(el); const selectedYear = calendarDate!.getFullYear(); const focusedYear = yearToDisplay == null ? selectedYear : yearToDisplay; let yearToChunk = focusedYear; yearToChunk -= yearToChunk % YEAR_CHUNK; yearToChunk = Math.max(0, yearToChunk); const prevYearChunkDisabled = isDatesYearOutsideMinOrMax( setYear(calendarDate!, yearToChunk - 1), minDate!, maxDate ); const nextYearChunkDisabled = isDatesYearOutsideMinOrMax( setYear(calendarDate!, yearToChunk + YEAR_CHUNK), minDate!, maxDate ); const years: HTMLButtonElement[] = []; let yearIndex = yearToChunk; while (years.length < YEAR_CHUNK) { const isDisabled = isDatesYearOutsideMinOrMax( setYear(calendarDate!, yearIndex), minDate!, maxDate ); let tabindex = '-1'; const classes = [CALENDAR_YEAR_CLASS]; const isSelected = yearIndex === selectedYear; if (yearIndex === focusedYear) { tabindex = '0'; classes.push(CALENDAR_YEAR_FOCUSED_CLASS); } if (isSelected) { classes.push(CALENDAR_YEAR_SELECTED_CLASS); } const btn = document.createElement('button'); btn.setAttribute('type', 'button'); btn.setAttribute('tabindex', tabindex); btn.setAttribute('class', classes.join(' ')); btn.setAttribute('data-value', String(yearIndex)); btn.setAttribute('aria-selected', isSelected ? 'true' : 'false'); if (isDisabled === true) { btn.disabled = true; } btn.textContent = String(yearIndex); years.push(btn); yearIndex += 1; } // create the years calendar wrapper const yearsCalendarWrapper = document.createElement('div'); yearsCalendarWrapper.setAttribute('tabindex', '-1'); yearsCalendarWrapper.setAttribute('class', CALENDAR_YEAR_PICKER_CLASS); // create table parent const yearsTableParent = document.createElement('table'); yearsTableParent.setAttribute('class', CALENDAR_TABLE_CLASS); // create table body and table row const yearsHTMLTableBody = document.createElement('tbody'); const yearsHTMLTableBodyRow = document.createElement('tr'); // create previous button const previousYearsBtn = document.createElement('button'); previousYearsBtn.setAttribute('type', 'button'); previousYearsBtn.setAttribute('class', CALENDAR_PREVIOUS_YEAR_CHUNK_CLASS); previousYearsBtn.setAttribute('aria-label', `Navigate back ${YEAR_CHUNK} years`); if (prevYearChunkDisabled === true) { previousYearsBtn.disabled = true; } previousYearsBtn.innerHTML = Sanitizer.escapeHTML` `; // create next button const nextYearsBtn = document.createElement('button'); nextYearsBtn.setAttribute('type', 'button'); nextYearsBtn.setAttribute('class', CALENDAR_NEXT_YEAR_CHUNK_CLASS); nextYearsBtn.setAttribute('aria-label', `Navigate forward ${YEAR_CHUNK} years`); if (nextYearChunkDisabled === true) { nextYearsBtn.disabled = true; } nextYearsBtn.innerHTML = Sanitizer.escapeHTML` `; // create the actual years table const yearsTable = document.createElement('table'); yearsTable.setAttribute('class', CALENDAR_TABLE_CLASS); yearsTable.setAttribute('role', 'presentation'); // create the years child table const yearsGrid = listToGridHtml(years, 3); const yearsTableBody = createTableBody(yearsGrid); // append the grid to the years child table yearsTable.insertAdjacentElement('beforeend', yearsTableBody); // create the prev button td and append the prev button const yearsHTMLTableBodyDetailPrev = document.createElement('td'); yearsHTMLTableBodyDetailPrev.insertAdjacentElement('beforeend', previousYearsBtn); // create the years td and append the years child table const yearsHTMLTableBodyYearsDetail = document.createElement('td'); yearsHTMLTableBodyYearsDetail.setAttribute('colspan', '3'); yearsHTMLTableBodyYearsDetail.insertAdjacentElement('beforeend', yearsTable); // create the next button td and append the next button const yearsHTMLTableBodyDetailNext = document.createElement('td'); yearsHTMLTableBodyDetailNext.insertAdjacentElement('beforeend', nextYearsBtn); // append the three td to the years child table row yearsHTMLTableBodyRow.insertAdjacentElement('beforeend', yearsHTMLTableBodyDetailPrev); yearsHTMLTableBodyRow.insertAdjacentElement('beforeend', yearsHTMLTableBodyYearsDetail); yearsHTMLTableBodyRow.insertAdjacentElement('beforeend', yearsHTMLTableBodyDetailNext); // append the table row to the years child table body yearsHTMLTableBody.insertAdjacentElement('beforeend', yearsHTMLTableBodyRow); // append the years table body to the years parent table yearsTableParent.insertAdjacentElement('beforeend', yearsHTMLTableBody); // append the parent table to the calendar wrapper yearsCalendarWrapper.insertAdjacentElement('beforeend', yearsTableParent); // CRITICAL: Update calendar in place instead of replacing to preserve element references calendarEl.innerHTML = ''; calendarEl.insertAdjacentElement('beforeend', yearsCalendarWrapper); statusEl.textContent = Sanitizer.escapeHTML`Showing years ${yearToChunk} to ${ yearToChunk + YEAR_CHUNK - 1 }. Select a year.`; return calendarEl; }; /** * Navigate back by years and display the year selection screen. * * SOURCE: index.js (Lines 1647-1670) * * @param el - An element within the date picker component */ const displayPreviousYearChunk = (el: HTMLElement): void => { if ((el as HTMLButtonElement).disabled) return; const { calendarEl, calendarDate, minDate, maxDate } = getDatePickerContext(el); const yearEl = calendarEl.querySelector(CALENDAR_YEAR_FOCUSED) as HTMLElement; const selectedYear = parseInt(yearEl.textContent!, 10); let adjustedYear = selectedYear - YEAR_CHUNK; adjustedYear = Math.max(0, adjustedYear); const date = setYear(calendarDate!, adjustedYear); const cappedDate = keepDateBetweenMinAndMax(date, minDate!, maxDate); const newCalendar = displayYearSelection(calendarEl, cappedDate.getFullYear()); let nextToFocus = newCalendar.querySelector(CALENDAR_PREVIOUS_YEAR_CHUNK) as HTMLElement; if ((nextToFocus as HTMLButtonElement).disabled) { nextToFocus = newCalendar.querySelector(CALENDAR_YEAR_PICKER) as HTMLElement; } nextToFocus.focus(); }; /** * Navigate forward by years and display the year selection screen. * * SOURCE: index.js (Lines 1677-1700) * * @param el - An element within the date picker component */ const displayNextYearChunk = (el: HTMLElement): void => { if ((el as HTMLButtonElement).disabled) return; const { calendarEl, calendarDate, minDate, maxDate } = getDatePickerContext(el); const yearEl = calendarEl.querySelector(CALENDAR_YEAR_FOCUSED) as HTMLElement; const selectedYear = parseInt(yearEl.textContent!, 10); let adjustedYear = selectedYear + YEAR_CHUNK; adjustedYear = Math.max(0, adjustedYear); const date = setYear(calendarDate!, adjustedYear); const cappedDate = keepDateBetweenMinAndMax(date, minDate!, maxDate); const newCalendar = displayYearSelection(calendarEl, cappedDate.getFullYear()); let nextToFocus = newCalendar.querySelector(CALENDAR_NEXT_YEAR_CHUNK) as HTMLElement; if ((nextToFocus as HTMLButtonElement).disabled) { nextToFocus = newCalendar.querySelector(CALENDAR_YEAR_PICKER) as HTMLElement; } nextToFocus.focus(); }; /** * Select a year in the date picker component. * * SOURCE: index.js (Lines 1707-1716) * * @param yearEl - A year element within the date picker component */ const selectYear = (yearEl: HTMLElement): void => { if ((yearEl as HTMLButtonElement).disabled) return; const { calendarEl, calendarDate, minDate, maxDate } = getDatePickerContext(yearEl); const selectedYear = parseInt(yearEl.innerHTML, 10); let date = setYear(calendarDate!, selectedYear); date = keepDateBetweenMinAndMax(date, minDate!, maxDate); const newCalendar = renderCalendar(calendarEl, date); (newCalendar.querySelector(CALENDAR_DATE_FOCUSED) as HTMLElement).focus(); }; /** * Calendar Event Handling - Keyboard Navigation * * SOURCE: index.js (Lines 1727-2112) */ /** * Hide the calendar with escape key * * SOURCE: index.js (Lines 1727-1734) */ const handleEscapeFromCalendar = (event: KeyboardEvent): void => { const { datePickerEl, externalInputEl } = getDatePickerContext(event.target as HTMLElement); hideCalendar(datePickerEl); externalInputEl.focus(); event.preventDefault(); }; /** * Adjust the date and display the calendar if needed. * * SOURCE: index.js (Lines 1745-1758) */ const adjustCalendar = (adjustDateFn: (date: Date) => Date) => (event: KeyboardEvent): void => { const { calendarEl, calendarDate, minDate, maxDate } = getDatePickerContext( event.target as HTMLElement ); const date = adjustDateFn(calendarDate!); const cappedDate = keepDateBetweenMinAndMax(date, minDate!, maxDate); if (!isSameDay(calendarDate, cappedDate)) { const newCalendar = renderCalendar(calendarEl, cappedDate); (newCalendar.querySelector(CALENDAR_DATE_FOCUSED) as HTMLElement).focus(); } event.preventDefault(); }; // Date navigation handlers const handleUpFromDate = adjustCalendar((date) => subWeeks(date, 1)); const handleDownFromDate = adjustCalendar((date) => addWeeks(date, 1)); const handleLeftFromDate = adjustCalendar((date) => subDays(date, 1)); const handleRightFromDate = adjustCalendar((date) => addDays(date, 1)); const handleHomeFromDate = adjustCalendar((date) => startOfWeek(date)); const handleEndFromDate = adjustCalendar((date) => endOfWeek(date)); const handlePageDownFromDate = adjustCalendar((date) => addMonths(date, 1)); const handlePageUpFromDate = adjustCalendar((date) => subMonths(date, 1)); const handleShiftPageDownFromDate = adjustCalendar((date) => addYears(date, 1)); const handleShiftPageUpFromDate = adjustCalendar((date) => subYears(date, 1)); /** * Set range date classes without re-rendering the calendar * * SOURCE: index.js (Lines 1837-1868) */ const handleMouseoverFromDate = (dateEl: HTMLElement): void => { if ((dateEl as HTMLButtonElement).disabled) return; const hoverDate = parseDateString((dateEl as HTMLElement).dataset.value); const { calendarEl, selectedDate, rangeDate } = getDatePickerContext(dateEl); if (selectedDate) return; const { withinRangeStartDate, withinRangeEndDate } = setRangeDates(hoverDate!, rangeDate); const dateButtons = calendarEl.querySelectorAll(`.${CALENDAR_DATE_CURRENT_MONTH_CLASS}`); dateButtons.forEach((button) => { const buttonDate = parseDateString((button as HTMLElement).dataset.value); if (isDateWithinMinAndMax(buttonDate!, withinRangeStartDate!, withinRangeEndDate)) { button.classList.add(CALENDAR_DATE_WITHIN_RANGE_CLASS); } else { button.classList.remove(CALENDAR_DATE_WITHIN_RANGE_CLASS); } }); }; /** * Adjust the month and display the month selection screen if needed. * * SOURCE: index.js (Lines 1879-1899) */ const adjustMonthSelectionScreen = (adjustMonthFn: (month: number) => number) => (event: KeyboardEvent): void => { const monthEl = event.target as HTMLElement; const selectedMonth = parseInt(monthEl.dataset.value!, 10); const { calendarEl, calendarDate, minDate, maxDate } = getDatePickerContext(monthEl); const currentDate = setMonth(calendarDate!, selectedMonth); let adjustedMonth = adjustMonthFn(selectedMonth); adjustedMonth = Math.max(0, Math.min(11, adjustedMonth)); const date = setMonth(calendarDate!, adjustedMonth); const cappedDate = keepDateBetweenMinAndMax(date, minDate!, maxDate); if (!isSameMonth(currentDate, cappedDate)) { const newCalendar = displayMonthSelection(calendarEl, cappedDate.getMonth()); (newCalendar.querySelector(CALENDAR_MONTH_FOCUSED) as HTMLElement).focus(); } event.preventDefault(); }; // Month navigation handlers const handleUpFromMonth = adjustMonthSelectionScreen((month) => month - 3); const handleDownFromMonth = adjustMonthSelectionScreen((month) => month + 3); const handleLeftFromMonth = adjustMonthSelectionScreen((month) => month - 1); const handleRightFromMonth = adjustMonthSelectionScreen((month) => month + 1); const handleHomeFromMonth = adjustMonthSelectionScreen((month) => month - (month % 3)); const handleEndFromMonth = adjustMonthSelectionScreen((month) => month + 2 - (month % 3)); const handlePageDownFromMonth = adjustMonthSelectionScreen(() => 11); const handlePageUpFromMonth = adjustMonthSelectionScreen(() => 0); /** * Adjust the year and display the year selection screen if needed. * * SOURCE: index.js (Lines 1970-1990) */ const adjustYearSelectionScreen = (adjustYearFn: (year: number) => number) => (event: KeyboardEvent): void => { const yearEl = event.target as HTMLElement; const selectedYear = parseInt(yearEl.dataset.value!, 10); const { calendarEl, calendarDate, minDate, maxDate } = getDatePickerContext(yearEl); const currentDate = setYear(calendarDate!, selectedYear); let adjustedYear = adjustYearFn(selectedYear); adjustedYear = Math.max(0, adjustedYear); const date = setYear(calendarDate!, adjustedYear); const cappedDate = keepDateBetweenMinAndMax(date, minDate!, maxDate); if (!isSameYear(currentDate, cappedDate)) { const newCalendar = displayYearSelection(calendarEl, cappedDate.getFullYear()); (newCalendar.querySelector(CALENDAR_YEAR_FOCUSED) as HTMLElement).focus(); } event.preventDefault(); }; // Year navigation handlers const handleUpFromYear = adjustYearSelectionScreen((year) => year - 3); const handleDownFromYear = adjustYearSelectionScreen((year) => year + 3); const handleLeftFromYear = adjustYearSelectionScreen((year) => year - 1); const handleRightFromYear = adjustYearSelectionScreen((year) => year + 1); const handleHomeFromYear = adjustYearSelectionScreen((year) => year - (year % 3)); const handleEndFromYear = adjustYearSelectionScreen((year) => year + 2 - (year % 3)); const handlePageUpFromYear = adjustYearSelectionScreen((year) => year - YEAR_CHUNK); const handlePageDownFromYear = adjustYearSelectionScreen((year) => year + YEAR_CHUNK); /** * Tab handler for focus management * * SOURCE: index.js (Lines 2060-2107) */ const tabHandler = (focusable: string) => { const getFocusableContext = (el: HTMLElement) => { const { calendarEl } = getDatePickerContext(el); const focusableElements = Array.from( calendarEl.querySelectorAll(focusable) ) as HTMLElement[]; const firstTabIndex = 0; const lastTabIndex = focusableElements.length - 1; const firstTabStop = focusableElements[firstTabIndex]; const lastTabStop = focusableElements[lastTabIndex]; const focusIndex = focusableElements.indexOf(document.activeElement as HTMLElement); const isLastTab = focusIndex === lastTabIndex; const isFirstTab = focusIndex === firstTabIndex; const isNotFound = focusIndex === -1; return { focusableElements, isNotFound, firstTabStop, isFirstTab, lastTabStop, isLastTab, }; }; return { tabAhead(this: HTMLElement, event: KeyboardEvent): void { const { firstTabStop, isLastTab, isNotFound } = getFocusableContext( event.target as HTMLElement ); if (isLastTab || isNotFound) { event.preventDefault(); firstTabStop.focus(); } }, tabBack(this: HTMLElement, event: KeyboardEvent): void { const { lastTabStop, isFirstTab, isNotFound } = getFocusableContext( event.target as HTMLElement ); if (isFirstTab || isNotFound) { event.preventDefault(); lastTabStop.focus(); } }, }; }; const datePickerTabEventHandler = tabHandler(DATE_PICKER_FOCUSABLE); const monthPickerTabEventHandler = tabHandler(MONTH_PICKER_FOCUSABLE); const yearPickerTabEventHandler = tabHandler(YEAR_PICKER_FOCUSABLE); /** * Keymap helper - inline implementation * * SOURCE: Adapted from receptor/keymap pattern */ function keymap(mappings: Record void>) { return function (this: HTMLElement, event: KeyboardEvent) { const key = event.shiftKey ? `Shift+${event.key}` : event.key; const handler = mappings[key]; if (handler) { handler.call(this, event); } }; } /** * Date Picker Event Delegation and Initialization * * SOURCE: index.js (Lines 2117-2281) */ /** * Initialize date picker with event delegation * * SOURCE: index.js (Lines 2266-2281) */ export function initializeDatePicker(root: HTMLElement | Document = document): () => void { const rootEl = root === document ? document.body : (root as HTMLElement); // Enhance all date pickers selectOrMatches(DATE_PICKER, root).forEach((datePickerEl) => { enhanceDatePicker(datePickerEl as HTMLElement); }); // Event delegation for click events const handleClick = function (this: HTMLElement, event: Event) { const target = event.target as HTMLElement; if (target.closest(DATE_PICKER_BUTTON)) { toggleCalendar.call(target.closest(DATE_PICKER_BUTTON)!, target.closest(DATE_PICKER_BUTTON)!); } else if (target.closest(CALENDAR_DATE)) { selectDate.call(target.closest(CALENDAR_DATE)!, target.closest(CALENDAR_DATE)!); } else if (target.closest(CALENDAR_MONTH)) { selectMonth.call(target.closest(CALENDAR_MONTH)!, target.closest(CALENDAR_MONTH)!); } else if (target.closest(CALENDAR_YEAR)) { selectYear.call(target.closest(CALENDAR_YEAR)!, target.closest(CALENDAR_YEAR)!); } else if (target.closest(CALENDAR_PREVIOUS_MONTH)) { displayPreviousMonth.call(target.closest(CALENDAR_PREVIOUS_MONTH)!, target.closest(CALENDAR_PREVIOUS_MONTH)!); } else if (target.closest(CALENDAR_NEXT_MONTH)) { displayNextMonth.call(target.closest(CALENDAR_NEXT_MONTH)!, target.closest(CALENDAR_NEXT_MONTH)!); } else if (target.closest(CALENDAR_PREVIOUS_YEAR)) { displayPreviousYear.call(target.closest(CALENDAR_PREVIOUS_YEAR)!, target.closest(CALENDAR_PREVIOUS_YEAR)!); } else if (target.closest(CALENDAR_NEXT_YEAR)) { displayNextYear.call(target.closest(CALENDAR_NEXT_YEAR)!, target.closest(CALENDAR_NEXT_YEAR)!); } else if (target.closest(CALENDAR_PREVIOUS_YEAR_CHUNK)) { displayPreviousYearChunk.call( target.closest(CALENDAR_PREVIOUS_YEAR_CHUNK)!, target.closest(CALENDAR_PREVIOUS_YEAR_CHUNK)! ); } else if (target.closest(CALENDAR_NEXT_YEAR_CHUNK)) { displayNextYearChunk.call(target.closest(CALENDAR_NEXT_YEAR_CHUNK)!, target.closest(CALENDAR_NEXT_YEAR_CHUNK)!); } else if (target.closest(CALENDAR_MONTH_SELECTION)) { const newCalendar = displayMonthSelection(target.closest(CALENDAR_MONTH_SELECTION)!); (newCalendar.querySelector(CALENDAR_MONTH_FOCUSED) as HTMLElement).focus(); } else if (target.closest(CALENDAR_YEAR_SELECTION)) { const newCalendar = displayYearSelection(target.closest(CALENDAR_YEAR_SELECTION)!); (newCalendar.querySelector(CALENDAR_YEAR_FOCUSED) as HTMLElement).focus(); } }; // Event delegation for keydown events const handleKeydown = function (this: HTMLElement, event: KeyboardEvent) { const target = event.target as HTMLElement; // External input validation on Enter if (target.closest(DATE_PICKER_EXTERNAL_INPUT)) { if (event.keyCode === ENTER_KEYCODE) { validateDateInput(target); } } // Calendar date navigation else if (target.closest(CALENDAR_DATE)) { const dateKeymap = keymap({ Up: handleUpFromDate, ArrowUp: handleUpFromDate, Down: handleDownFromDate, ArrowDown: handleDownFromDate, Left: handleLeftFromDate, ArrowLeft: handleLeftFromDate, Right: handleRightFromDate, ArrowRight: handleRightFromDate, Home: handleHomeFromDate, End: handleEndFromDate, PageDown: handlePageDownFromDate, PageUp: handlePageUpFromDate, 'Shift+PageDown': handleShiftPageDownFromDate, 'Shift+PageUp': handleShiftPageUpFromDate, Tab: datePickerTabEventHandler.tabAhead, }); dateKeymap.call(target, event); } // Calendar date picker container else if (target.closest(CALENDAR_DATE_PICKER)) { const pickerKeymap = keymap({ Tab: datePickerTabEventHandler.tabAhead, 'Shift+Tab': datePickerTabEventHandler.tabBack, }); pickerKeymap.call(target, event); } // Month selection navigation else if (target.closest(CALENDAR_MONTH)) { const monthKeymap = keymap({ Up: handleUpFromMonth, ArrowUp: handleUpFromMonth, Down: handleDownFromMonth, ArrowDown: handleDownFromMonth, Left: handleLeftFromMonth, ArrowLeft: handleLeftFromMonth, Right: handleRightFromMonth, ArrowRight: handleRightFromMonth, Home: handleHomeFromMonth, End: handleEndFromMonth, PageDown: handlePageDownFromMonth, PageUp: handlePageUpFromMonth, }); monthKeymap.call(target, event); } // Month picker container else if (target.closest(CALENDAR_MONTH_PICKER)) { const monthPickerKeymap = keymap({ Tab: monthPickerTabEventHandler.tabAhead, 'Shift+Tab': monthPickerTabEventHandler.tabBack, }); monthPickerKeymap.call(target, event); } // Year selection navigation else if (target.closest(CALENDAR_YEAR)) { const yearKeymap = keymap({ Up: handleUpFromYear, ArrowUp: handleUpFromYear, Down: handleDownFromYear, ArrowDown: handleDownFromYear, Left: handleLeftFromYear, ArrowLeft: handleLeftFromYear, Right: handleRightFromYear, ArrowRight: handleRightFromYear, Home: handleHomeFromYear, End: handleEndFromYear, PageDown: handlePageDownFromYear, PageUp: handlePageUpFromYear, }); yearKeymap.call(target, event); } // Year picker container else if (target.closest(CALENDAR_YEAR_PICKER)) { const yearPickerKeymap = keymap({ Tab: yearPickerTabEventHandler.tabAhead, 'Shift+Tab': yearPickerTabEventHandler.tabBack, }); yearPickerKeymap.call(target, event); } // Calendar general (store keycode) else if (target.closest(DATE_PICKER_CALENDAR)) { const calendarEl = target.closest(DATE_PICKER_CALENDAR) as HTMLElement; calendarEl.dataset.keydownKeyCode = String(event.keyCode); } // Date picker escape handling else if (target.closest(DATE_PICKER)) { const escapeKeymap = keymap({ Escape: handleEscapeFromCalendar, }); escapeKeymap.call(target, event); } }; // Event delegation for keyup events const handleKeyup = function (this: HTMLElement, event: KeyboardEvent) { const target = event.target as HTMLElement; if (target.closest(DATE_PICKER_CALENDAR)) { const calendarEl = target.closest(DATE_PICKER_CALENDAR) as HTMLElement; const keydown = calendarEl.dataset.keydownKeyCode; if (String(event.keyCode) !== keydown) { event.preventDefault(); } } }; // Event delegation for focusout events const handleFocusout = function (this: HTMLElement, event: FocusEvent) { const target = event.target as HTMLElement; if (target.closest(DATE_PICKER_EXTERNAL_INPUT)) { validateDateInput(target); } else if (target.closest(DATE_PICKER)) { const datePickerEl = target.closest(DATE_PICKER) as HTMLElement; if (!datePickerEl.contains(event.relatedTarget as HTMLElement)) { hideCalendar(datePickerEl); } } }; // Event delegation for input events const handleInput = function (this: HTMLElement, event: Event) { const target = event.target as HTMLElement; if (target.closest(DATE_PICKER_EXTERNAL_INPUT)) { reconcileInputValues(target); updateCalendarIfVisible(target); } }; // Event delegation for change events (for validation) const handleChange = function (this: HTMLElement, event: Event) { const target = event.target as HTMLElement; if (target.closest(DATE_PICKER_EXTERNAL_INPUT)) { validateDateInput(target); } }; // Event delegation for blur events (for validation - alternative to focusout) const handleBlur = function (this: HTMLElement, event: Event) { const target = event.target as HTMLElement; if (target.closest(DATE_PICKER_EXTERNAL_INPUT)) { validateDateInput(target); } }; // Event delegation for mouseover events (non-iOS only) const handleMouseover = function (this: HTMLElement, event: MouseEvent) { const target = event.target as HTMLElement; if (target.closest(CALENDAR_DATE_CURRENT_MONTH)) { handleMouseoverFromDate(target.closest(CALENDAR_DATE_CURRENT_MONTH)!); } }; // CRITICAL: Document-level escape handler // The Escape key should close the calendar even when the event is dispatched on document itself // This handles synthetic keyboard events in tests and ensures global Escape key support const handleDocumentKeydown = (event: KeyboardEvent) => { if (event.key === 'Escape') { // Find all date pickers with open calendars and close them const datePickers = document.querySelectorAll(DATE_PICKER); datePickers.forEach((picker) => { const calendar = picker.querySelector(DATE_PICKER_CALENDAR) as HTMLElement; if (calendar && !calendar.hidden) { const externalInput = picker.querySelector(DATE_PICKER_EXTERNAL_INPUT) as HTMLInputElement; hideCalendar(picker as HTMLElement); if (externalInput) { externalInput.focus(); } event.preventDefault(); } }); } }; // Attach all event listeners rootEl.addEventListener('click', handleClick as EventListener); rootEl.addEventListener('keydown', handleKeydown as EventListener); rootEl.addEventListener('keyup', handleKeyup as EventListener); rootEl.addEventListener('focusout', handleFocusout as EventListener); rootEl.addEventListener('input', handleInput as EventListener); rootEl.addEventListener('change', handleChange as EventListener); rootEl.addEventListener('blur', handleBlur as EventListener, true); // Use capture for blur // Listen on document for Escape key to handle events dispatched directly on document document.addEventListener('keydown', handleDocumentKeydown as EventListener); // Add mouseover only for non-iOS devices const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); if (!isIOS) { rootEl.addEventListener('mouseover', handleMouseover as EventListener); } // Return cleanup function return () => { rootEl.removeEventListener('click', handleClick as EventListener); rootEl.removeEventListener('keydown', handleKeydown as EventListener); rootEl.removeEventListener('keyup', handleKeyup as EventListener); rootEl.removeEventListener('focusout', handleFocusout as EventListener); rootEl.removeEventListener('input', handleInput as EventListener); rootEl.removeEventListener('change', handleChange as EventListener); rootEl.removeEventListener('blur', handleBlur as EventListener, true); // Use capture for blur document.removeEventListener('keydown', handleDocumentKeydown as EventListener); if (!isIOS) { rootEl.removeEventListener('mouseover', handleMouseover as EventListener); } }; } export { DATE_PICKER_CLASS, CALENDAR_DATE_CLASS, VALIDATION_MESSAGE, DEFAULT_EXTERNAL_DATE_FORMAT, INTERNAL_DATE_FORMAT, };