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 = [];
}
}