import { cn } from "../../lib/cn"; import { FloatingPortal } from "./floating-portal"; import { useEffect, useId, useMemo, useRef, useState, type InputHTMLAttributes, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { emitInputChange, fieldClassName, fieldNote, formatTimeLabel, formatTimeValue, padTimeUnit, parseTimeValue, PICKER_PANEL_RADIUS, PICKER_TRACK_RADIUS, readStepMinutes, resolvePopupPlacement, type FieldMeta, type PopupPlacement, } from "./shared"; export type TimePickerProps = InputHTMLAttributes & FieldMeta; export function TimePicker({ className, defaultValue, disabled, error, hint, id, label, name, onChange, placeholder, required, step, value, }: TimePickerProps) { const generatedId = useId(); const fieldId = id ?? generatedId; const hintId = hint ? `${fieldId}-hint` : undefined; const errorId = error ? `${fieldId}-error` : undefined; const panelRef = useRef(null); const buttonRef = useRef(null); const timePanelRef = useRef(null); const timeOverlayRef = useRef(null); const hourColumnRef = useRef(null); const minuteColumnRef = useRef(null); const controlledValue = typeof value === "string" ? value : undefined; const [internalValue, setInternalValue] = useState( typeof defaultValue === "string" ? defaultValue : "", ); const selectedValue = controlledValue ?? internalValue; const parsedTime = parseTimeValue(selectedValue); const [open, setOpen] = useState(false); const minuteStep = readStepMinutes(step); const minuteOptions = useMemo(() => { const values: number[] = []; for (let minute = 0; minute < 60; minute += minuteStep) { values.push(minute); } return values; }, [minuteStep]); const selectedTime = { hours: parsedTime?.hours ?? 9, minutes: minuteOptions.includes(parsedTime?.minutes ?? -1) ? (parsedTime?.minutes ?? 0) : (minuteOptions[0] ?? 0), }; const [activeColumn, setActiveColumn] = useState<"hours" | "minutes">("hours"); const [activeHour, setActiveHour] = useState(selectedTime.hours); const [activeMinute, setActiveMinute] = useState(selectedTime.minutes); const [, setPlacement] = useState({ horizontal: "left", vertical: "bottom", }); useEffect(() => { function handlePointerDown(event: PointerEvent) { if (!panelRef.current?.contains(event.target as Node)) { setOpen(false); } } function handleEscape(event: KeyboardEvent) { if (event.key === "Escape") { setOpen(false); } } window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleEscape); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleEscape); }; }, []); useEffect(() => { setActiveHour(selectedTime.hours); setActiveMinute(selectedTime.minutes); }, [selectedTime.hours, selectedTime.minutes]); useEffect(() => { if (open) { timePanelRef.current?.focus(); requestAnimationFrame(() => { if (buttonRef.current && timeOverlayRef.current) { setPlacement( resolvePopupPlacement( buttonRef.current.getBoundingClientRect(), timeOverlayRef.current.getBoundingClientRect(), ), ); } hourColumnRef.current ?.querySelector(`[data-time-hour="${activeHour}"]`) ?.scrollIntoView({ block: "nearest" }); minuteColumnRef.current ?.querySelector(`[data-time-minute="${activeMinute}"]`) ?.scrollIntoView({ block: "nearest" }); }); } }, [activeHour, activeMinute, open]); function emitChange(nextValue: string) { if (controlledValue === undefined) { setInternalValue(nextValue); } emitInputChange(onChange, nextValue, name); } function handleSelect(nextHours: number, nextMinutes: number, shouldClose = true) { emitChange(formatTimeValue(nextHours, nextMinutes)); setActiveHour(nextHours); setActiveMinute(nextMinutes); if (shouldClose) { setOpen(false); buttonRef.current?.focus(); } } function handleKeyDown(event: ReactKeyboardEvent) { if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") { event.preventDefault(); setOpen((current) => !current); } } function handlePanelKeyDown(event: ReactKeyboardEvent) { if (event.key === "ArrowRight") { event.preventDefault(); setActiveColumn("minutes"); return; } if (event.key === "ArrowLeft") { event.preventDefault(); setActiveColumn("hours"); return; } if (event.key === "ArrowUp") { event.preventDefault(); if (activeColumn === "hours") { setActiveHour((current) => (current - 1 + 24) % 24); } else { const index = minuteOptions.indexOf(activeMinute); const nextIndex = (index - 1 + minuteOptions.length) % minuteOptions.length; setActiveMinute(minuteOptions[nextIndex] ?? 0); } return; } if (event.key === "ArrowDown") { event.preventDefault(); if (activeColumn === "hours") { setActiveHour((current) => (current + 1) % 24); } else { const index = minuteOptions.indexOf(activeMinute); const nextIndex = (index + 1) % minuteOptions.length; setActiveMinute(minuteOptions[nextIndex] ?? 0); } return; } if (event.key === "Home") { event.preventDefault(); if (activeColumn === "hours") { setActiveHour(0); } else { setActiveMinute(minuteOptions[0] ?? 0); } return; } if (event.key === "End") { event.preventDefault(); if (activeColumn === "hours") { setActiveHour(23); } else { setActiveMinute(minuteOptions[minuteOptions.length - 1] ?? 0); } return; } if (event.key === "Enter" || event.key === " ") { event.preventDefault(); handleSelect(activeHour, activeMinute); } } return ( ); }