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