import { autoUpdate, computePosition, flip, offset as offsetMiddleware, shift } from '@floating-ui/dom'; import { uid } from '../shared/dom'; import { TIME_PICKER_CHANGE_EVENT, TIME_PICKER_CLOSE_EVENT, TIME_PICKER_OPEN_EVENT, type Placement, type TimeFormat, type TimePickerChangeDetail, type TimePickerOptions, type TimePickerState, } from './time-picker.types'; const SELECTORS = { trigger: '[data-c42-timepicker-trigger]', panel: '[data-c42-timepicker-panel]', value: '[data-c42-timepicker-value]', hour: '[data-c42-timepicker-hour]', minute: '[data-c42-timepicker-minute]', period: '[data-c42-timepicker-period]', confirm: '[data-c42-timepicker-confirm]', cancel: '[data-c42-timepicker-cancel]', } as const; interface Parsed { hours: number; minutes: number; } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } /** Parse a 24h `HH:MM` string into hours/minutes, or null when invalid. */ function parseTime(value: string | null | undefined): Parsed | null { if (!value) { return null; } const match = /^(\d{1,2}):(\d{1,2})$/.exec(value.trim()); if (!match) { return null; } const hours = Number(match[1]); const minutes = Number(match[2]); if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) { return null; } return { hours, minutes }; } function pad(value: number): string { return value.toString().padStart(2, '0'); } function toISO(hours: number, minutes: number): string { return `${pad(hours)}:${pad(minutes)}`; } /** * Headless time picker. Owns hour/minute inputs and an optional AM/PM toggle, * commits a 24h `HH:MM` value on confirm and reflects open state via * `data-state`. Positioning via floating-ui; no visual styling in JS. * * Markup: * ```html *
* *
* * * * * *
*
* ``` */ export class TimePicker { private readonly root: HTMLElement; private readonly trigger: HTMLElement; private readonly panel: HTMLElement; private readonly valueEl: HTMLElement | null; private readonly hourInput: HTMLInputElement; private readonly minuteInput: HTMLInputElement; private readonly periodBtn: HTMLElement | null; private readonly confirmBtn: HTMLElement | null; private readonly cancelBtn: HTMLElement | null; private readonly format: TimeFormat; private readonly minuteStep: number; private readonly placeholder: string; private readonly closeOnConfirm: boolean; private readonly placement: Placement; private readonly offset: number; private selected: Parsed | null; private period: 'AM' | 'PM' = 'AM'; private open = false; private stopAutoUpdate: (() => void) | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: TimePickerOptions = {}) { const trigger = root.querySelector(SELECTORS.trigger); const panel = root.querySelector(SELECTORS.panel); const hourInput = root.querySelector(SELECTORS.hour); const minuteInput = root.querySelector(SELECTORS.minute); if (!trigger || !panel || !hourInput || !minuteInput) { throw new Error('[42/time-picker] Needs a trigger, a panel, an hour and a minute input.'); } this.root = root; this.trigger = trigger; this.panel = panel; this.hourInput = hourInput; this.minuteInput = minuteInput; this.valueEl = root.querySelector(SELECTORS.value); this.periodBtn = panel.querySelector(SELECTORS.period); this.confirmBtn = panel.querySelector(SELECTORS.confirm); this.cancelBtn = panel.querySelector(SELECTORS.cancel); this.format = options.format ?? '12h'; this.minuteStep = Math.max(1, options.minuteStep ?? 1); this.placeholder = options.placeholder ?? ''; this.closeOnConfirm = options.closeOnConfirm ?? true; this.placement = options.placement ?? 'bottom-start'; this.offset = options.offset ?? 4; this.selected = parseTime(options.defaultValue); this.init(); } private init(): void { const panelId = this.panel.id || uid('timepicker-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('hidden', ''); this.panel.dataset.state = 'closed'; this.trigger.dataset.state = 'closed'; if (this.periodBtn) { this.periodBtn.toggleAttribute('hidden', this.format === '24h'); } const onTriggerClick = (): void => this.toggle(); const onHourInput = (): void => this.sanitize(this.hourInput, this.format === '12h' ? 12 : 23); const onMinuteInput = (): void => this.sanitize(this.minuteInput, 59); const onPeriod = (): void => this.togglePeriod(); const onConfirm = (): void => this.confirm(); const onCancel = (): void => this.cancel(); const onOutside = (event: Event): void => this.onOutside(event); this.trigger.addEventListener('click', onTriggerClick); this.hourInput.addEventListener('input', onHourInput); this.minuteInput.addEventListener('input', onMinuteInput); this.periodBtn?.addEventListener('click', onPeriod); this.confirmBtn?.addEventListener('click', onConfirm); this.cancelBtn?.addEventListener('click', onCancel); document.addEventListener('pointerdown', onOutside, true); this.cleanups.push( () => this.trigger.removeEventListener('click', onTriggerClick), () => this.hourInput.removeEventListener('input', onHourInput), () => this.minuteInput.removeEventListener('input', onMinuteInput), () => this.periodBtn?.removeEventListener('click', onPeriod), () => this.confirmBtn?.removeEventListener('click', onConfirm), () => this.cancelBtn?.removeEventListener('click', onCancel), () => document.removeEventListener('pointerdown', onOutside, true), ); this.renderLabel(); this.syncInputs(); } /** Strip non-digits and clamp the field to its max as the user types. */ private sanitize(input: HTMLInputElement, max: number): void { const digits = input.value.replace(/\D/g, '').slice(0, 2); if (digits === '') { input.value = ''; return; } const min = input === this.hourInput && this.format === '12h' ? 1 : 0; input.value = String(clamp(Number(digits), min, max)); } private renderLabel(): void { if (!this.valueEl) { return; } this.valueEl.textContent = this.selected ? this.formatDisplay(this.selected) : this.placeholder; this.valueEl.toggleAttribute('data-placeholder', !this.selected); } private formatDisplay({ hours, minutes }: Parsed): string { if (this.format === '24h') { return toISO(hours, minutes); } const period = hours >= 12 ? 'PM' : 'AM'; const display = hours % 12 || 12; return `${display}:${pad(minutes)} ${period}`; } /** Populate the inputs/period from the current selection (or now). */ private syncInputs(): void { const base = this.selected ?? this.nowParsed(); if (this.format === '24h') { this.hourInput.value = pad(base.hours); } else { this.period = base.hours >= 12 ? 'PM' : 'AM'; this.hourInput.value = String(base.hours % 12 || 12); this.renderPeriod(); } this.minuteInput.value = pad(base.minutes); } private nowParsed(): Parsed { const now = new Date(); return { hours: now.getHours(), minutes: now.getMinutes() }; } private togglePeriod(): void { this.period = this.period === 'AM' ? 'PM' : 'AM'; this.renderPeriod(); } private renderPeriod(): void { if (this.periodBtn) { this.periodBtn.textContent = this.period; this.periodBtn.dataset.period = this.period; } } /** Read the inputs into a normalized 24h Parsed value. */ private readInputs(): Parsed { const rawHour = Number(this.hourInput.value || '0'); const minutesRaw = clamp(Number(this.minuteInput.value || '0'), 0, 59); const minutes = Math.round(minutesRaw / this.minuteStep) * this.minuteStep; let hours: number; if (this.format === '24h') { hours = clamp(rawHour, 0, 23); } else { const h12 = clamp(rawHour, 1, 12); if (this.period === 'PM') { hours = h12 === 12 ? 12 : h12 + 12; } else { hours = h12 === 12 ? 0 : h12; } } return { hours, minutes: clamp(minutes, 0, 59) }; } confirm(): void { this.selected = this.readInputs(); this.renderLabel(); this.emitChange(); if (this.closeOnConfirm) { this.close(); this.trigger.focus(); } } cancel(): void { this.syncInputs(); this.close(); this.trigger.focus(); } private emitChange(): void { const detail: TimePickerChangeDetail = { value: this.selected ? toISO(this.selected.hours, this.selected.minutes) : null, hours: this.selected?.hours ?? null, minutes: this.selected?.minutes ?? null, }; this.root.dispatchEvent(new CustomEvent(TIME_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.syncInputs(); this.panel.removeAttribute('hidden'); this.panel.dataset.state = 'open'; this.trigger.dataset.state = 'open'; this.trigger.setAttribute('aria-expanded', 'true'); this.stopAutoUpdate = autoUpdate(this.trigger, this.panel, () => void this.position()); this.hourInput.focus(); this.hourInput.select(); this.root.dispatchEvent(new CustomEvent(TIME_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(TIME_PICKER_CLOSE_EVENT, { bubbles: true })); } /** Programmatically set the time (24h `HH:MM`). */ setValue(value: string | null): void { this.selected = parseTime(value); this.renderLabel(); this.syncInputs(); this.emitChange(); } 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.hours, this.selected.minutes) : null; } get isOpen(): boolean { return this.open; } getState(): TimePickerState { return { value: this.value, open: this.open }; } 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 = []; } }