import { autoUpdate, computePosition, flip, offset as offsetMiddleware, shift } from '@floating-ui/dom'; import { uid } from '../shared/dom'; import { DATE_PICKER_CHANGE_EVENT, DATE_PICKER_CLOSE_EVENT, DATE_PICKER_OPEN_EVENT, type DateInput, type DatePickerChangeDetail, type DatePickerOptions, type DatePickerState, type Placement, } from './date-picker.types'; const SELECTORS = { trigger: '[data-c42-datepicker-trigger]', panel: '[data-c42-datepicker-panel]', value: '[data-c42-datepicker-value]', label: '[data-c42-datepicker-label]', grid: '[data-c42-datepicker-grid]', prev: '[data-c42-datepicker-prev]', next: '[data-c42-datepicker-next]', } as const; const DAYS_IN_WEEK = 7; const WEEKS_SHOWN = 6; /* ---------- pure date helpers ---------- */ function startOfDay(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } function startOfMonth(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), 1); } function addDays(date: Date, amount: number): Date { return new Date(date.getFullYear(), date.getMonth(), date.getDate() + amount); } function addMonths(date: Date, amount: number): Date { return new Date(date.getFullYear(), date.getMonth() + amount, 1); } function isSameDay(a: Date | null, b: Date | null): boolean { if (!a || !b) { return false; } return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } function toISO(date: Date): string { const y = date.getFullYear().toString().padStart(4, '0'); const m = (date.getMonth() + 1).toString().padStart(2, '0'); const d = date.getDate().toString().padStart(2, '0'); return `${y}-${m}-${d}`; } /** Parse an ISO `YYYY-MM-DD` string or `Date` into a local-midnight `Date`. */ function parseDate(value: DateInput | null | undefined): Date | null { if (value == null) { return null; } if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : startOfDay(value); } const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value); if (!match) { return null; } const [, y, m, d] = match; return new Date(Number(y), Number(m) - 1, Number(d)); } /** * Headless date picker. Owns a calendar grid it renders into the consumer's * markup, manages selection/navigation state, ARIA wiring, keyboard support and * floating-ui positioning. No visual styling is applied in JS — state is * reflected via `data-state`, `data-today`, `data-outside` and `aria-*`. * * Markup: * ```html *
* *
*
* * * *
*
*
*
* ``` */ export class DatePicker { private readonly root: HTMLElement; private readonly trigger: HTMLElement; private readonly panel: HTMLElement; private readonly grid: HTMLElement; private readonly valueEl: HTMLElement | null; private readonly labelEl: HTMLElement | null; private readonly prevBtn: HTMLElement | null; private readonly nextBtn: HTMLElement | null; private readonly locale: string; private readonly weekStartsOn: number; private readonly placeholder: string; private readonly closeOnSelect: boolean; private readonly placement: Placement; private readonly offset: number; private readonly formatLabel?: (date: Date) => string; private readonly min: Date | null; private readonly max: Date | null; private readonly mode: 'single' | 'range'; private selected: Date | null; private rangeStart: Date | null; private rangeEnd: Date | null; private hoverDate: Date | null = null; private viewMonth: Date; private focused: Date; private open = false; private stopAutoUpdate: (() => void) | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: DatePickerOptions = {}) { const trigger = root.querySelector(SELECTORS.trigger); const panel = root.querySelector(SELECTORS.panel); const grid = root.querySelector(SELECTORS.grid); if (!trigger || !panel || !grid) { throw new Error('[42/date-picker] Needs a trigger, a panel and a grid element.'); } this.root = root; this.trigger = trigger; this.panel = panel; this.grid = grid; this.valueEl = root.querySelector(SELECTORS.value); this.labelEl = panel.querySelector(SELECTORS.label); this.prevBtn = panel.querySelector(SELECTORS.prev); this.nextBtn = panel.querySelector(SELECTORS.next); this.locale = options.locale ?? 'en-US'; this.weekStartsOn = options.weekStartsOn ?? 0; this.placeholder = options.placeholder ?? ''; this.closeOnSelect = options.closeOnSelect ?? true; this.placement = options.placement ?? 'bottom-start'; this.offset = options.offset ?? 4; this.formatLabel = options.formatLabel; this.min = parseDate(options.min); this.max = parseDate(options.max); this.mode = options.mode ?? 'single'; this.selected = parseDate(options.defaultValue); this.rangeStart = options.defaultRange ? parseDate(options.defaultRange.start) : null; this.rangeEnd = options.defaultRange ? parseDate(options.defaultRange.end) : null; const initial = this.selected ?? this.rangeStart ?? startOfDay(new Date()); this.viewMonth = startOfMonth(initial); this.focused = initial; this.init(); } private init(): void { const panelId = this.panel.id || uid('datepicker-panel'); this.panel.id = panelId; this.trigger.setAttribute('aria-haspopup', 'dialog'); this.trigger.setAttribute('aria-controls', panelId); this.trigger.setAttribute('aria-expanded', 'false'); this.panel.setAttribute('role', 'dialog'); this.panel.setAttribute('aria-modal', 'false'); this.panel.setAttribute('hidden', ''); this.panel.dataset.state = 'closed'; this.trigger.dataset.state = 'closed'; this.grid.setAttribute('role', 'grid'); const onTriggerClick = (): void => this.toggle(); const onPrev = (): void => this.goToMonth(-1); const onNext = (): void => this.goToMonth(1); const onGridClick = (event: Event): void => this.onGridClick(event); const onGridKeydown = (event: Event): void => this.onGridKeydown(event as KeyboardEvent); const onGridMouseover = (event: Event): void => this.onGridMouseover(event); const onOutside = (event: Event): void => this.onOutside(event); this.trigger.addEventListener('click', onTriggerClick); this.prevBtn?.addEventListener('click', onPrev); this.nextBtn?.addEventListener('click', onNext); this.grid.addEventListener('click', onGridClick); this.grid.addEventListener('keydown', onGridKeydown); this.grid.addEventListener('mouseover', onGridMouseover); document.addEventListener('pointerdown', onOutside, true); this.cleanups.push( () => this.trigger.removeEventListener('click', onTriggerClick), () => this.prevBtn?.removeEventListener('click', onPrev), () => this.nextBtn?.removeEventListener('click', onNext), () => this.grid.removeEventListener('click', onGridClick), () => this.grid.removeEventListener('keydown', onGridKeydown), () => this.grid.removeEventListener('mouseover', onGridMouseover), () => document.removeEventListener('pointerdown', onOutside, true), ); this.renderLabel(); this.renderGrid(); } /* ---------- rendering ---------- */ private renderLabel(): void { if (!this.valueEl) return; if (this.mode === 'range') { if (this.rangeStart && this.rangeEnd) { this.valueEl.textContent = `${this.format(this.rangeStart)} – ${this.format(this.rangeEnd)}`; } else if (this.rangeStart) { this.valueEl.textContent = `${this.format(this.rangeStart)} – …`; } else { this.valueEl.textContent = this.placeholder; } this.valueEl.toggleAttribute('data-placeholder', !this.rangeStart); } else { this.valueEl.textContent = this.selected ? this.format(this.selected) : this.placeholder; this.valueEl.toggleAttribute('data-placeholder', !this.selected); } } private format(date: Date): string { if (this.formatLabel) { return this.formatLabel(date); } return new Intl.DateTimeFormat(this.locale, { year: 'numeric', month: 'short', day: 'numeric', }).format(date); } private weekdayNames(): string[] { const fmt = new Intl.DateTimeFormat(this.locale, { weekday: 'short' }); // 2024-01-07 is a Sunday; build the 7 names rotated by weekStartsOn. return Array.from({ length: DAYS_IN_WEEK }, (_, i) => { const dayIndex = (this.weekStartsOn + i) % DAYS_IN_WEEK; return fmt.format(new Date(2024, 0, 7 + dayIndex)); }); } private isDisabled(date: Date): boolean { if (this.min && date < this.min) { return true; } if (this.max && date > this.max) { return true; } return false; } private renderGrid(): void { if (this.labelEl) { this.labelEl.textContent = new Intl.DateTimeFormat(this.locale, { month: 'long', year: 'numeric', }).format(this.viewMonth); } const today = startOfDay(new Date()); const firstOfMonth = startOfMonth(this.viewMonth); const leading = (firstOfMonth.getDay() - this.weekStartsOn + DAYS_IN_WEEK) % DAYS_IN_WEEK; const gridStart = addDays(firstOfMonth, -leading); const frag = document.createDocumentFragment(); const headerRow = document.createElement('div'); headerRow.setAttribute('role', 'row'); headerRow.dataset.c42DatepickerWeekdays = ''; for (const name of this.weekdayNames()) { const cell = document.createElement('span'); cell.setAttribute('role', 'columnheader'); cell.setAttribute('aria-label', name); cell.dataset.c42DatepickerWeekday = ''; cell.textContent = name; headerRow.appendChild(cell); } frag.appendChild(headerRow); for (let week = 0; week < WEEKS_SHOWN; week += 1) { const row = document.createElement('div'); row.setAttribute('role', 'row'); for (let day = 0; day < DAYS_IN_WEEK; day += 1) { const date = addDays(gridStart, week * DAYS_IN_WEEK + day); row.appendChild(this.renderDay(date, today)); } frag.appendChild(row); } this.grid.replaceChildren(frag); } private renderDay(date: Date, today: Date): HTMLElement { const cell = document.createElement('div'); cell.setAttribute('role', 'gridcell'); const button = document.createElement('button'); button.type = 'button'; button.dataset.c42DatepickerDay = ''; button.dataset.value = toISO(date); button.textContent = String(date.getDate()); const outside = date.getMonth() !== this.viewMonth.getMonth(); const disabled = this.isDisabled(date); const isToday = isSameDay(date, today); if (outside) { button.dataset.outside = ''; } if (isToday) { button.dataset.today = ''; } // Determine state based on mode let state = 'unselected'; if (this.mode === 'range') { const start = this.rangeStart; const end = this.rangeEnd ?? this.hoverDate; if (start && isSameDay(date, start)) { state = 'range-start'; } else if (end && start && isSameDay(date, end)) { state = 'range-end'; } else if (start && end) { const [lo, hi] = start <= end ? [start, end] : [end, start]; if (date > lo && date < hi) { state = 'in-range'; } // Re-check start/end for swapped range if (isSameDay(date, lo)) state = 'range-start'; if (isSameDay(date, hi)) state = 'range-end'; } } else { if (isSameDay(date, this.selected)) { state = 'selected'; } } button.dataset.state = state; button.setAttribute('aria-selected', String(state === 'selected' || state === 'range-start' || state === 'range-end')); if (disabled) { button.disabled = true; button.setAttribute('aria-disabled', 'true'); } button.tabIndex = isSameDay(date, this.focused) ? 0 : -1; cell.appendChild(button); return cell; } /* ---------- interaction ---------- */ private onGridClick(event: Event): void { const target = (event.target as HTMLElement).closest('[data-c42-datepicker-day]'); if (!target || target.disabled || !this.grid.contains(target)) { return; } const date = parseDate(target.dataset.value); if (date) { this.select(date); } } private onGridMouseover(event: Event): void { if (this.mode !== 'range' || !this.rangeStart || this.rangeEnd) return; const target = (event.target as HTMLElement).closest('[data-c42-datepicker-day]'); if (!target || !this.grid.contains(target)) return; const date = parseDate(target.dataset.value); if (date && !isSameDay(date, this.hoverDate)) { this.hoverDate = date; this.renderGrid(); } } private onGridKeydown(event: KeyboardEvent): void { let next: Date | null = null; switch (event.key) { case 'ArrowRight': next = addDays(this.focused, 1); break; case 'ArrowLeft': next = addDays(this.focused, -1); break; case 'ArrowDown': next = addDays(this.focused, DAYS_IN_WEEK); break; case 'ArrowUp': next = addDays(this.focused, -DAYS_IN_WEEK); break; case 'Home': next = addDays(this.focused, -((this.focused.getDay() - this.weekStartsOn + DAYS_IN_WEEK) % DAYS_IN_WEEK)); break; case 'End': next = addDays(this.focused, DAYS_IN_WEEK - 1 - ((this.focused.getDay() - this.weekStartsOn + DAYS_IN_WEEK) % DAYS_IN_WEEK)); break; case 'PageUp': next = addMonths(this.viewMonth, -1); next = new Date(next.getFullYear(), next.getMonth(), Math.min(this.focused.getDate(), 28)); break; case 'PageDown': next = addMonths(this.viewMonth, 1); next = new Date(next.getFullYear(), next.getMonth(), Math.min(this.focused.getDate(), 28)); break; case 'Enter': case ' ': event.preventDefault(); if (!this.isDisabled(this.focused)) { this.select(this.focused); } return; case 'Escape': event.preventDefault(); this.close(); this.trigger.focus(); return; default: return; } if (next) { event.preventDefault(); this.moveFocus(next); } } private moveFocus(date: Date): void { this.focused = startOfDay(date); if ( this.focused.getMonth() !== this.viewMonth.getMonth() || this.focused.getFullYear() !== this.viewMonth.getFullYear() ) { this.viewMonth = startOfMonth(this.focused); } this.renderGrid(); this.focusActiveDay(); } private focusActiveDay(): void { const target = this.grid.querySelector('[data-c42-datepicker-day][tabindex="0"]'); target?.focus(); } private goToMonth(delta: number): void { this.viewMonth = addMonths(this.viewMonth, delta); this.renderGrid(); } private select(date: Date): void { if (this.mode === 'range') { this.selectRange(date); return; } this.selected = startOfDay(date); this.focused = this.selected; this.viewMonth = startOfMonth(this.selected); this.renderLabel(); this.renderGrid(); this.emitChange(); if (this.closeOnSelect) { this.close(); this.trigger.focus(); } } private selectRange(date: Date): void { const d = startOfDay(date); if (!this.rangeStart || this.rangeEnd) { // First click or reset: set start this.rangeStart = d; this.rangeEnd = null; this.hoverDate = null; } else { // Second click: set end (normalize order) if (d < this.rangeStart) { this.rangeEnd = this.rangeStart; this.rangeStart = d; } else { this.rangeEnd = d; } this.hoverDate = null; } this.focused = d; this.renderLabel(); this.renderGrid(); if (this.rangeEnd) { this.emitChange(); if (this.closeOnSelect) { this.close(); this.trigger.focus(); } } } private emitChange(): void { const detail: DatePickerChangeDetail = { value: this.selected ? toISO(this.selected) : null, date: this.selected ? new Date(this.selected) : null, range: this.rangeStart && this.rangeEnd ? { start: toISO(this.rangeStart), end: toISO(this.rangeEnd) } : null, }; this.root.dispatchEvent( new CustomEvent(DATE_PICKER_CHANGE_EVENT, { detail, bubbles: true }), ); } private onOutside(event: Event): void { if (this.open && !this.root.contains(event.target as Node)) { this.close(); } } /* ---------- public API ---------- */ toggle(): void { if (this.open) { this.close(); } else { this.openPanel(); } } openPanel(): void { if (this.open) { return; } this.open = true; this.panel.removeAttribute('hidden'); this.panel.dataset.state = 'open'; this.trigger.dataset.state = 'open'; this.trigger.setAttribute('aria-expanded', 'true'); this.focused = this.selected ?? startOfDay(new Date()); this.viewMonth = startOfMonth(this.focused); this.renderGrid(); this.stopAutoUpdate = autoUpdate(this.trigger, this.panel, () => void this.position()); this.focusActiveDay(); this.root.dispatchEvent(new CustomEvent(DATE_PICKER_OPEN_EVENT, { bubbles: true })); } close(): void { if (!this.open) { return; } this.open = false; this.panel.setAttribute('hidden', ''); this.panel.dataset.state = 'closed'; this.trigger.dataset.state = 'closed'; this.trigger.setAttribute('aria-expanded', 'false'); this.stopAutoUpdate?.(); this.stopAutoUpdate = null; this.root.dispatchEvent(new CustomEvent(DATE_PICKER_CLOSE_EVENT, { bubbles: true })); } /** Programmatically set the selected date (ISO string or Date). */ setValue(value: DateInput | null): void { const parsed = parseDate(value); this.selected = parsed; if (parsed) { this.focused = parsed; this.viewMonth = startOfMonth(parsed); } this.renderLabel(); this.renderGrid(); this.emitChange(); } /** Clear the current selection. */ clear(): void { this.setValue(null); } private async position(): Promise { const { x, y } = await computePosition(this.trigger, this.panel, { placement: this.placement, middleware: [offsetMiddleware(this.offset), flip(), shift({ padding: 5 })], }); Object.assign(this.panel.style, { left: `${x}px`, top: `${y}px`, position: 'absolute' }); } get value(): string | null { return this.selected ? toISO(this.selected) : null; } get isOpen(): boolean { return this.open; } getState(): DatePickerState { return { value: this.value, open: this.open, range: this.rangeStart && this.rangeEnd ? { start: toISO(this.rangeStart), end: toISO(this.rangeEnd) } : null, }; } on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.close(); this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }