import { type ReactElement } from "react"; import * as React from "react"; import { ChevronDown, Clock } from "lucide-react"; import { cn } from "@/lib/utils"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; /** * TimePicker — WealthX Design System * * A compact **segmented** time input (openstatus-style): `HH : MM AM/PM`. Each * segment is directly editable — type digits, use ↑/↓ to increment/decrement * (values loop), and ←/→ to move between segments. Two-digit entry auto-advances * to the next segment. Keyboard-first, no long dropdown list. * * Controlled: `value` / `onChange` use a 24-hour `"HH:MM"` string (e.g. * `"09:00"`, `"17:30"`), so it drops into forms alongside `` * values without conversion. * * `minuteStep` constrains the minute to a grid — `30` allows only `:00`/`:30` * (arrow keys jump by it; typed minutes snap to the nearest). Default `1`. * * `selectable` adds a chevron that opens a scrollable list of times (on the * `minuteStep` grid), so the value can be picked with the mouse like a date * picker — the segments stay keyboard-editable. Default `false`. */ type Period = "AM" | "PM"; const pad = (n: number) => String(n).padStart(2, "0"); function label12(h: number, m: number): string { const h12 = h % 12 === 0 ? 12 : h % 12; return `${h12}:${pad(m)} ${h < 12 ? "AM" : "PM"}`; } // Falls back to an hourly grid when minuteStep is too fine (<5) to list, so a // default any-minute picker never builds 1440 rows. function buildTimeOptions(minuteStep: number): { value: string; label: string }[] { const step = minuteStep >= 5 ? minuteStep : 60; const options: { value: string; label: string }[] = []; for (let mins = 0; mins < 24 * 60; mins += step) { const h = Math.floor(mins / 60); const m = mins % 60; options.push({ value: `${pad(h)}:${pad(m)}`, label: label12(h, m) }); } return options; } /** Parse a 24h "HH:MM" string into 12-hour parts. */ function to12(value?: string): { hour: number; minute: number; period: Period; } { if (!value) return { hour: 12, minute: 0, period: "AM" }; const [h, m] = value.split(":"); const h24 = Math.min(23, Math.max(0, Number(h) || 0)); const minute = Math.min(59, Math.max(0, Number(m) || 0)); const period: Period = h24 < 12 ? "AM" : "PM"; const hour = h24 % 12 === 0 ? 12 : h24 % 12; return { hour, minute, period }; } /** Build a 24h "HH:MM" string from 12-hour parts. */ function to24({ hour, minute, period, }: { hour: number; minute: number; period: Period; }): string { let h = hour % 12; if (period === "PM") h += 12; return `${pad(h)}:${pad(minute)}`; } interface TimeSegmentProps { value: number; min: number; max: number; /** Increment size for arrow keys, and the grid typed values snap to. Default 1. */ step?: number; ariaLabel: string; disabled?: boolean; onChange: (n: number) => void; onLeftFocus?: () => void; onRightFocus?: () => void; } /** One editable numeric segment (hour or minute) with type + arrow-key entry. */ const TimeSegment = React.forwardRef( function TimeSegment( { value, min, max, step = 1, ariaLabel, disabled, onChange, onLeftFocus, onRightFocus, }, ref ): ReactElement { const [text, setText] = React.useState(pad(value)); // true once a first digit is typed and a second digit could still fit. const awaitingSecond = React.useRef(false); // Nearest in-range multiple of `step` (identity when step <= 1). const snap = React.useCallback( (n: number): number => { const clamped = Math.min(max, Math.max(min, n)); if (step <= 1) return clamped; let s = Math.round(clamped / step) * step; if (s > max) s -= step; if (s < min) s = min; return s; }, [min, max, step] ); const maxValid = snap(max); React.useEffect(() => { if (!awaitingSecond.current) setText(pad(value)); }, [value]); const commit = (n: number, advance: boolean) => { const snapped = snap(n); awaitingSecond.current = false; setText(pad(snapped)); onChange(snapped); if (advance) onRightFocus?.(); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Tab") return; e.preventDefault(); if (e.key === "ArrowRight") return onRightFocus?.(); if (e.key === "ArrowLeft") return onLeftFocus?.(); if (e.key === "ArrowUp" || e.key === "ArrowDown") { let n = value + (e.key === "ArrowUp" ? step : -step); if (n > maxValid) n = min; // loop if (n < min) n = maxValid; awaitingSecond.current = false; setText(pad(n)); onChange(n); return; } if (/^[0-9]$/.test(e.key)) { const d = Number(e.key); if (!awaitingSecond.current) { if (d * 10 <= max) { // A second digit could still land within range — show it and wait. awaitingSecond.current = true; setText(String(d)); if (d >= min) onChange(snap(d)); } else { commit(d, true); } } else { commit(Number(`${text}${d}`), true); } return; } if (e.key === "Backspace") { awaitingSecond.current = false; setText(pad(min)); onChange(min); } }; return ( {}} onKeyDown={handleKeyDown} onFocus={(e) => e.currentTarget.select()} onBlur={() => { // Re-sync to the committed value if a lone digit was left mid-entry. awaitingSecond.current = false; setText(pad(value)); }} aria-label={ariaLabel} disabled={disabled} className="w-[2ch] bg-transparent text-center text-body-medium tabular-nums caret-transparent outline-none focus:bg-primary/10 disabled:cursor-not-allowed" /> ); } ); interface PeriodSegmentProps { value: Period; disabled?: boolean; onChange: (p: Period) => void; onLeftFocus?: () => void; } /** AM/PM segment — toggle with ↑/↓ or type "a" / "p". */ const PeriodSegment = React.forwardRef( function PeriodSegment( { value, disabled, onChange, onLeftFocus }, ref ): ReactElement { const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Tab") return; e.preventDefault(); if (e.key === "ArrowLeft") return onLeftFocus?.(); if (e.key === "ArrowUp" || e.key === "ArrowDown") { return onChange(value === "AM" ? "PM" : "AM"); } if (e.key.toLowerCase() === "a") onChange("AM"); if (e.key.toLowerCase() === "p") onChange("PM"); }; return ( e.currentTarget.select()} aria-label="AM or PM" disabled={disabled} className="w-[3ch] bg-transparent text-center text-body-medium caret-transparent outline-none focus:bg-primary/10 disabled:cursor-not-allowed" /> ); } ); export interface TimePickerProps { /** 24-hour time as "HH:MM" (e.g. "09:00", "17:30"). */ value?: string; onChange?: (value: string) => void; disabled?: boolean; /** Render the error (destructive) border/ring. */ invalid?: boolean; /** * Minute granularity. Arrow keys step by this, and any typed minute snaps to * the nearest multiple — e.g. `30` restricts the minute to `:00`/`:30`. * Default `1` (any minute). */ minuteStep?: number; /** Add a chevron that opens a scrollable, clickable list of times. Default false. */ selectable?: boolean; id?: string; className?: string; } export function TimePicker({ value, onChange, disabled, invalid, minuteStep = 1, selectable = false, id, className, }: TimePickerProps): ReactElement { const { hour, minute, period } = to12(value); const hourRef = React.useRef(null); const minuteRef = React.useRef(null); const periodRef = React.useRef(null); const [open, setOpen] = React.useState(false); const selectedOptionRef = React.useRef(null); const emit = ( next: Partial<{ hour: number; minute: number; period: Period }> ) => onChange?.( to24({ hour: next.hour ?? hour, minute: next.minute ?? minute, period: next.period ?? period, }) ); const currentValue = to24({ hour, minute, period }); const options = React.useMemo( () => (selectable ? buildTimeOptions(minuteStep) : []), [selectable, minuteStep] ); React.useEffect(() => { if (open) selectedOptionRef.current?.scrollIntoView({ block: "center" }); }, [open]); const boxClassName = cn( "flex h-9 w-full items-center border border-input bg-transparent px-3 shadow-xs transition-[color,box-shadow] outline-none", "focus-within:border-primary focus-within:ring-[3px] focus-within:ring-primary/20", "aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20", disabled && "pointer-events-none opacity-50", className ); const segments = ( <> emit({ hour: h })} onRightFocus={() => minuteRef.current?.focus()} /> : emit({ minute: m })} onLeftFocus={() => hourRef.current?.focus()} onRightFocus={() => periodRef.current?.focus()} /> emit({ period: p })} onLeftFocus={() => minuteRef.current?.focus()} /> ); if (!selectable) { return (
hourRef.current?.focus()} className={boxClassName} > {segments}
); } return (
{segments}
} >
{options.map((option) => { const isSelected = option.value === currentValue; return ( ); })} ); } export default TimePicker;