{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"time-picker","type":"registry:ui","title":"TimePicker","description":"A field that opens a floating listbox of time slots generated at a configurable step, fully keyboard-navigable, with the highlighted slot always scrolled into view.","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["forms"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/time-picker/TimePicker.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type {\n\tComponentPropsWithoutRef,\n\tKeyboardEvent as ReactKeyboardEvent,\n\tMouseEvent as ReactMouseEvent,\n} from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { useField } from \"../../internals/field\";\nimport { useListbox } from \"../../internals/listbox\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useEventCallback } from \"../../internals/dom/use-event-callback\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport { filterByBounds, formatSlotLabel, generateSlots, nearestIndex } from \"./time-utils\";\nimport { TimePickerPanel } from \"./TimePickerPanel\";\nimport { TimePickerReactContext } from \"./types\";\nimport type { TimePickerContext } from \"./types\";\nimport \"./time-picker.css\";\n\n/**\n * Every other native `<button>` attribute and listener (`aria-*`, `data-*`,\n * `onFocus`, `onBlur`, `onKeyDown`, `form`, ...) is forwarded to the trigger\n * button. The managed ARIA (`role`, `aria-expanded`, `aria-controls`,\n * `aria-haspopup`, `aria-invalid`, `aria-required`, `aria-activedescendant`) stays the component's own.\n */\nexport interface TimePickerProps extends Omit<\n\tComponentPropsWithoutRef<\"button\">,\n\t\"className\" | \"id\" | \"name\" | \"value\" | \"disabled\" | \"type\" | \"role\" | \"children\"\n> {\n\t/** The selected time, or `null` for none. Always \"HH:mm\", 24-hour — see the README. */\n\tvalue?: string | null;\n\t/** Called with the new value whenever a slot is picked. */\n\tonValueChange?: (value: string | null) => void;\n\t/** Minutes between generated slots. Defaults to 30. See the README for what happens when it doesn't divide the hour evenly. */\n\tstep?: number;\n\t/** Earliest selectable slot (\"HH:mm\", inclusive). */\n\tmin?: string;\n\t/** Latest selectable slot (\"HH:mm\", inclusive). */\n\tmax?: string;\n\t/** Display only — the trigger and slot labels use a 12-hour clock with AM/PM when true. The value stays \"HH:mm\" either way. */\n\thour12?: boolean;\n\t/** Blocks opening the panel; excluded from form submission. Overridden by a surrounding FormField. */\n\tdisabled?: boolean;\n\t/** Marks the field required for the surrounding form. Overridden by a surrounding FormField. */\n\trequired?: boolean;\n\t/** Drives the error border and `aria-invalid`. Overridden by a surrounding FormField. */\n\tinvalid?: boolean;\n\t/** Element id. Overridden by a surrounding FormField's own `controlId`. */\n\tid?: string;\n\t/** Native `name` — when set, a hidden input carries the \"HH:mm\" value so the control participates in real form submission. */\n\tname?: string;\n\t/** Accessible name — for a control with no visible Label next to it. */\n\tlabel?: string;\n\t/** Shown in the trigger while no time is selected. */\n\tplaceholder?: string;\n\t/** BCP 47 locale for slot and trigger-label formatting. Defaults to the runtime's own locale. */\n\tlocale?: string;\n\t/** Additional CSS classes, merged onto the trigger button. */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off by\n\t * default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\n/**\n * A combobox trigger plus a portalled listbox of \"HH:mm\" slots.\n *\n * `forwardRef` because the source declares `ref = $bindable(null)` on the\n * trigger button — the exposed element is the `<button>`, not a wrapper.\n */\nexport const TimePicker = forwardRef<HTMLButtonElement, TimePickerProps>(function TimePicker(\n\t{\n\t\tvalue: valueProp,\n\t\tonValueChange,\n\t\tstep = 30,\n\t\tmin,\n\t\tmax,\n\t\thour12 = false,\n\t\tdisabled = false,\n\t\trequired = false,\n\t\tinvalid = false,\n\t\tid,\n\t\tname,\n\t\tlabel,\n\t\tplaceholder = \"Select a time\",\n\t\tlocale,\n\t\tclassName,\n\t\tsound = false,\n\t\tonClick,\n\t\tonKeyDown,\n\t\t...restProps\n\t},\n\tforwardedRef\n) {\n\t// Undefined outside a FormField — every value below then falls back to\n\t// this component's own props instead of the context, so the control works\n\t// standalone exactly as it does wrapped. The root is a real `<button>`, a\n\t// labelable element, so a wrapping FormField's own `<Label htmlFor>`\n\t// targets `controlId` directly.\n\tconst field = useField();\n\n\tconst effectiveId = field?.controlId ?? id;\n\tconst effectiveDisabled = field?.disabled ?? disabled;\n\tconst effectiveRequired = field?.required ?? required;\n\tconst effectiveInvalid = field?.invalid ?? invalid;\n\n\t// `useFancyId()`, the counterpart of the source's `$props.id()`: this seeds\n\t// the panel and option ids, which must already agree with themselves on the\n\t// very first server-rendered paint.\n\tconst uid = useFancyId();\n\tconst panelId = `${uid}-listbox`;\n\tconst optionId = useCallback((index: number) => `${uid}-option-${index}`, [uid]);\n\n\tconst playCue = useSoundCue(sound);\n\n\tconst [open, setOpen] = useState(false);\n\n\t// Convention C-1: the NODE, not a ref. The panel anchors against the\n\t// trigger and the dismiss layer excludes it, and both are hooks keyed on\n\t// the node.\n\tconst [triggerNode, setTriggerNode] = useElementRef<HTMLButtonElement>();\n\tconst triggerRef = useComposedRefs(setTriggerNode, forwardedRef);\n\n\t// Also the NODE rather than a plain ref, unlike `Select`'s equivalent: the\n\t// open-time scroll below has to fire the moment the portalled panel exists,\n\t// and only a state-backed node re-runs an effect when it appears.\n\tconst [panelNode, setPanelNode] = useElementRef<HTMLDivElement>();\n\n\t// The React shape of the source's `value = $bindable(null)`: an internal\n\t// copy seeded from the prop, re-synced during render whenever the CALLER\n\t// changes the prop, and free to move on its own in between. That is what\n\t// makes all three documented call shapes work off one implementation — a\n\t// caller driving `value` from its own state, a caller who passes only\n\t// `onValueChange`, and a caller who passes neither.\n\tconst [value, setValueState] = useState<string | null>(valueProp ?? null);\n\tconst [lastValueProp, setLastValueProp] = useState(valueProp);\n\tif (lastValueProp !== valueProp) {\n\t\tsetLastValueProp(valueProp);\n\t\tsetValueState(valueProp ?? null);\n\t}\n\n\tconst slots = useMemo(() => filterByBounds(generateSlots(step), min, max), [step, min, max]);\n\tconst selectedIndex = value ? slots.indexOf(value) : -1;\n\n\t// One formatted label per slot, computed once for the life of a grid rather\n\t// than once per render. The panel calls `labelFor` for every row it draws,\n\t// and every pointer hover re-renders it to move the highlight — without\n\t// this, each hover rebuilds all 48 labels (1440 at `step={1}`), and each\n\t// label builds its own `Intl.DateTimeFormat`. Nothing on screen changes for\n\t// that work.\n\t//\n\t// Filled on demand rather than up front: a picker that is never opened only\n\t// ever formats the trigger's own value, and paying for the whole grid at\n\t// mount would be a worse trade than the one this removes. Thrown away\n\t// wholesale whenever the grid or the formatting inputs change, so an entry\n\t// can never outlive what produced it.\n\tconst labelCache = useMemo(() => new Map<string, string>(), [slots, hour12, locale]);\n\n\t// Still a function, so the context shape is unchanged. `value` can sit off\n\t// the grid (a caller-supplied \"14:05\" against a 30-minute step), so the\n\t// cache is keyed by slot rather than by index.\n\tconst labelFor = useCallback(\n\t\t(slot: string) => {\n\t\t\tconst cached = labelCache.get(slot);\n\t\t\tif (cached !== undefined) return cached;\n\t\t\tconst formatted = formatSlotLabel(slot, hour12, locale);\n\t\t\tlabelCache.set(slot, formatted);\n\t\t\treturn formatted;\n\t\t},\n\t\t[labelCache, hour12, locale]\n\t);\n\n\t// The single place `value` changes, in either direction (a consumer-owned\n\t// `value` prop or `onValueChange`) — a plain function, never an effect, so\n\t// it can never read and write `value` in the same pass and never fights a\n\t// caller's own controlled write.\n\t/** Returns true when the value actually changed (and a `select` cue played). */\n\tfunction setValue(next: string | null): boolean {\n\t\tif (value === next) return false;\n\t\tsetValueState(next);\n\t\tplayCue(\"select\");\n\t\tonValueChange?.(next);\n\t\treturn true;\n\t}\n\n\tfunction commitIndex(index: number): boolean {\n\t\tconst slot = slots[index];\n\t\tif (!slot) return false;\n\t\treturn setValue(slot);\n\t}\n\n\t// Identity-stable: the listbox store is built once and reads this through a\n\t// live ref, so it must never go stale on `panelNode` or `optionId`.\n\tconst scrollActiveIntoView = useEventCallback((index: number) => {\n\t\tconst row = panelNode?.querySelector<HTMLElement>(`#${CSS.escape(optionId(index))}`);\n\t\trow?.scrollIntoView?.({ block: \"nearest\" });\n\t});\n\n\tconst listbox = useListbox({\n\t\tcount: slots.length,\n\t\tonActiveChange: scrollActiveIntoView,\n\t});\n\n\t// `slots` is regenerated whenever `step`, `min` or `max` change, and the\n\t// listbox store knows nothing about that: it only reacts to explicit\n\t// move/typeahead/setActive calls, never to the grid changing shape between\n\t// them. An index that was valid under the old grid therefore survives into\n\t// the new one — pointing past the end of a shorter list, so\n\t// `aria-activedescendant` below cites an option id with no row left in the\n\t// DOM, or (a shifted grid: a later `min`, a coarser `step`) pointing at a\n\t// completely different TIME than the one the user arrowed to, with the\n\t// highlight silently jumping under them.\n\t//\n\t// So the index is reconciled against the time it named, not clamped as a\n\t// number: the previous grid says which slot was active, and `nearestIndex`\n\t// re-resolves that slot in the new grid exactly as `openPanel` resolves\n\t// `value` — same slot if it survived, else the closest one at or after it,\n\t// else the last, else -1 for a grid with no slots at all. A bounds change\n\t// therefore lands the highlight where reopening the panel would.\n\t//\n\t// DIVERGENCE: the Svelte source has the same gap. `Select` clamps to -1\n\t// instead, which is right there — its options are an opaque caller-supplied\n\t// list with no ordering to resolve a missing entry against, while these\n\t// slots are times on a line.\n\t//\n\t// The listbox handle is read through a live ref rather than listed as a\n\t// dependency: `useListbox` returns a fresh object whenever `activeIndex`\n\t// moves, so depending on it would tear this effect down and re-run it on\n\t// every hover and every arrow key, only to bail on the guard above. The\n\t// grid changing shape is the one thing it reacts to.\n\tconst listboxRef = useLiveRef(listbox);\n\tconst previousSlotsRef = useRef(slots);\n\tuseEffect(() => {\n\t\tconst previous = previousSlotsRef.current;\n\t\tpreviousSlotsRef.current = slots;\n\t\tif (previous === slots) return;\n\n\t\tconst box = listboxRef.current;\n\t\tconst index = box.activeIndex;\n\t\tif (index === -1) return;\n\n\t\tconst activeSlot = previous[index];\n\t\tbox.setActive(activeSlot === undefined ? -1 : nearestIndex(slots, activeSlot));\n\t}, [listboxRef, slots]);\n\n\t// The index owed a scroll as soon as the panel exists. The source spells\n\t// this `tick().then(() => scrollActiveIntoView(index))`; here the panel is\n\t// mounted by its own presence clock a commit later, so the request is\n\t// parked and the effect below pays it the instant the node appears.\n\t//\n\t// Always scrolled into view on open, not only relying on `onActiveChange` —\n\t// that callback only fires when the index actually *changes*, and a reopen\n\t// can land on the same index a previous session left active, which must\n\t// still be visible the instant the panel appears rather than only after the\n\t// next arrow press.\n\t//\n\t// A LAYOUT effect, not a passive one: this writes `scrollTop`, and a write\n\t// that lands after the paint is a visible jump — a picker whose value sits\n\t// late in the grid would show the list parked at 00:00 for a frame and then\n\t// snap to the selection. The source's `tick()` runs after the DOM update\n\t// and before the paint, which is this phase.\n\tconst pendingScrollRef = useRef<number | null>(null);\n\n\tuseIsomorphicLayoutEffect(() => {\n\t\tconst index = pendingScrollRef.current;\n\t\tif (index === null || !panelNode) return;\n\t\tpendingScrollRef.current = null;\n\t\tscrollActiveIntoView(index);\n\t\t// `open` is a dependency as well as the node: a reopen that lands while\n\t\t// the previous panel is still finishing its exit reuses the very same\n\t\t// node, so `[panelNode]` alone would never re-run.\n\t}, [panelNode, open, scrollActiveIntoView]);\n\n\tfunction openPanel(): void {\n\t\tif (effectiveDisabled) return;\n\t\tsetOpen(true);\n\t\tplayCue(\"open\");\n\t\tconst index = nearestIndex(slots, value);\n\t\tif (index === -1) return;\n\t\tlistbox.setActive(index);\n\t\tpendingScrollRef.current = index;\n\t}\n\n\t// `reason` distinguishes a commit-flavoured close (a slot was just picked)\n\t// from a plain dismiss (Escape, an outside click, or the trigger toggling\n\t// the panel shut with nothing committed). Only a dismiss plays the `close`\n\t// cue — a commit already played `select` inside `setValue` above, and the\n\t// contract is one cue per interaction, never both.\n\tfunction closePanel(reason: \"commit\" | \"dismiss\" = \"dismiss\"): void {\n\t\tsetOpen(false);\n\t\tif (reason === \"dismiss\") playCue(\"close\");\n\t}\n\n\tfunction commitActiveAndClose(): void {\n\t\t// The close reason follows the ACTUAL outcome: re-committing the slot\n\t\t// already selected changes nothing, so it closes like a dismiss rather\n\t\t// than being swallowed into silence.\n\t\tconst committed = listbox.activeIndex !== -1 && commitIndex(listbox.activeIndex);\n\t\tclosePanel(committed ? \"commit\" : \"dismiss\");\n\t}\n\n\tfunction handleTriggerClick(): void {\n\t\tif (effectiveDisabled) return;\n\t\tif (open) closePanel();\n\t\telse openPanel();\n\t\t// Deliberate, not incidental: a plain `<button>` is only guaranteed to\n\t\t// take focus on click in some browsers (macOS Safari notably does not,\n\t\t// by default). Focus needs to be on the trigger for the keyboard\n\t\t// interactions below (arrows, Home/End, Enter) to have anything to\n\t\t// attach to right after a mouse open.\n\t\ttriggerNode?.focus();\n\t}\n\n\t// No Escape handling here on purpose — `TimePickerPanel`'s own dismiss\n\t// layer already closes on Escape (and an outside click) via the\n\t// document-level listener it owns; a second listener here would be the\n\t// exact bug this wave already had to remove once. Because Escape only ever\n\t// closes and closing never itself writes `value`, \"Escape closes without\n\t// committing\" falls out for free.\n\tfunction handleTriggerKeydown(event: ReactKeyboardEvent<HTMLButtonElement>): void {\n\t\tif (effectiveDisabled) return;\n\n\t\tif (!open) {\n\t\t\tswitch (event.key) {\n\t\t\t\tcase \"Enter\":\n\t\t\t\tcase \" \":\n\t\t\t\tcase \"ArrowDown\":\n\t\t\t\tcase \"ArrowUp\":\n\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\topenPanel();\n\t\t\t\t\treturn;\n\t\t\t\tdefault:\n\t\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tswitch (event.key) {\n\t\t\tcase \"ArrowDown\":\n\t\t\t\tevent.preventDefault();\n\t\t\t\tlistbox.move(1);\n\t\t\t\treturn;\n\t\t\tcase \"ArrowUp\":\n\t\t\t\tevent.preventDefault();\n\t\t\t\tlistbox.move(-1);\n\t\t\t\treturn;\n\t\t\tcase \"Home\":\n\t\t\t\tevent.preventDefault();\n\t\t\t\tlistbox.moveToEdge(\"first\");\n\t\t\t\treturn;\n\t\t\tcase \"End\":\n\t\t\t\tevent.preventDefault();\n\t\t\t\tlistbox.moveToEdge(\"last\");\n\t\t\t\treturn;\n\t\t\tcase \"Enter\":\n\t\t\tcase \" \":\n\t\t\t\tevent.preventDefault();\n\t\t\t\tcommitActiveAndClose();\n\t\t\t\treturn;\n\t\t}\n\t}\n\n\t// Identity-stable wrappers, so the context object below rebuilds only when\n\t// something a consumer RENDERS actually changed.\n\tconst contextSetActive = useEventCallback((index: number) => {\n\t\tlistbox.setActive(index);\n\t});\n\tconst contextCommit = useEventCallback((index: number) => {\n\t\tconst committed = commitIndex(index);\n\t\t// A POINTER commit is the one path that can strand focus. Pressing a\n\t\t// row moves focus onto it (the row carries `tabIndex={-1}`), and the\n\t\t// panel is portalled to `<body>`, so it has no focusable ancestor to\n\t\t// inherit focus once it goes inert for the exit and then unmounts —\n\t\t// `document.activeElement` would fall back to `<body>` and the next Tab\n\t\t// would restart from the top of the document. Dropping the row's\n\t\t// `tabIndex` would NOT fix that: a click on a non-focusable element\n\t\t// blurs the trigger to `<body>` just the same. The\n\t\t// combobox-with-listbox-popup contract is that focus never leaves the\n\t\t// trigger, so put it back explicitly, before the close starts the exit.\n\t\t// The keyboard path is unaffected — focus was on the trigger the whole\n\t\t// time, and re-focusing an already-focused element is a no-op.\n\t\ttriggerNode?.focus();\n\t\tclosePanel(committed ? \"commit\" : \"dismiss\");\n\t});\n\t// Wrapped so a caller passing an event object can never leak it in as `reason`.\n\tconst contextClose = useEventCallback(() => {\n\t\tclosePanel(\"dismiss\");\n\t});\n\n\tconst activeIndex = listbox.activeIndex;\n\tconst context = useMemo<TimePickerContext>(\n\t\t() => ({\n\t\t\topen,\n\t\t\tpanelId,\n\t\t\tslots,\n\t\t\tactiveIndex,\n\t\t\tlabelFor,\n\t\t\ttriggerRef: triggerNode,\n\t\t\toptionId,\n\t\t\tisSelected: (index: number) => index === selectedIndex,\n\t\t\tisActive: (index: number) => index === activeIndex,\n\t\t\tsetActive: contextSetActive,\n\t\t\tcommit: contextCommit,\n\t\t\tclose: contextClose,\n\t\t}),\n\t\t[\n\t\t\topen,\n\t\t\tpanelId,\n\t\t\tslots,\n\t\t\tactiveIndex,\n\t\t\tlabelFor,\n\t\t\ttriggerNode,\n\t\t\toptionId,\n\t\t\tselectedIndex,\n\t\t\tcontextSetActive,\n\t\t\tcontextCommit,\n\t\t\tcontextClose,\n\t\t]\n\t);\n\n\tconst triggerLabel = value ? labelFor(value) : undefined;\n\n\t// Internal handling first, the consumer's listener once after it. A disabled\n\t// control runs neither (a disabled <button> would not fire them anyway).\n\tfunction handleClick(event: ReactMouseEvent<HTMLButtonElement>): void {\n\t\tif (effectiveDisabled) return;\n\t\thandleTriggerClick();\n\t\tonClick?.(event);\n\t}\n\n\tfunction handleKeyDown(event: ReactKeyboardEvent<HTMLButtonElement>): void {\n\t\tif (effectiveDisabled) return;\n\t\thandleTriggerKeydown(event);\n\t\tonKeyDown?.(event);\n\t}\n\n\t// A consumer `aria-describedby` is merged with the FormField's, never replaces it.\n\tconst describedBy =\n\t\t[field?.describedBy, restProps[\"aria-describedby\"]].filter(Boolean).join(\" \") || undefined;\n\n\tconst classes = cn(\n\t\t\"ft-time-picker-trigger flex w-full items-center justify-between gap-2 rounded-[8px] border border-input bg-background px-[12px] py-[9px] text-left text-[13px] text-foreground transition-colors\",\n\t\t\"focus-visible:outline-none\",\n\t\t\"disabled:cursor-not-allowed disabled:opacity-50\",\n\t\teffectiveInvalid && \"border-destructive/50\",\n\t\tclassName\n\t);\n\n\treturn (\n\t\t<TimePickerReactContext.Provider value={context}>\n\t\t\t<button\n\t\t\t\t{...restProps}\n\t\t\t\tref={triggerRef}\n\t\t\t\tid={effectiveId}\n\t\t\t\ttype=\"button\"\n\t\t\t\trole=\"combobox\"\n\t\t\t\taria-haspopup=\"listbox\"\n\t\t\t\taria-expanded={open}\n\t\t\t\taria-controls={open ? panelId : undefined}\n\t\t\t\taria-activedescendant={open && activeIndex !== -1 ? optionId(activeIndex) : undefined}\n\t\t\t\taria-invalid={effectiveInvalid ? \"true\" : restProps[\"aria-invalid\"]}\n\t\t\t\taria-required={effectiveRequired ? \"true\" : restProps[\"aria-required\"]}\n\t\t\t\taria-describedby={describedBy}\n\t\t\t\taria-label={label ?? restProps[\"aria-label\"]}\n\t\t\t\tdisabled={effectiveDisabled}\n\t\t\t\tclassName={classes}\n\t\t\t\tonClick={handleClick}\n\t\t\t\tonKeyDown={handleKeyDown}\n\t\t\t>\n\t\t\t\t<span className={triggerLabel ? undefined : \"text-muted-foreground\"}>\n\t\t\t\t\t{triggerLabel ?? placeholder}\n\t\t\t\t</span>\n\t\t\t\t<span aria-hidden=\"true\" className=\"text-muted-foreground\">\n\t\t\t\t\t◷\n\t\t\t\t</span>\n\t\t\t</button>\n\n\t\t\t{name ? (\n\t\t\t\t<input\n\t\t\t\t\ttype=\"hidden\"\n\t\t\t\t\tname={name}\n\t\t\t\t\tvalue={value ?? \"\"}\n\t\t\t\t\tform={restProps.form}\n\t\t\t\t\tdisabled={effectiveDisabled}\n\t\t\t\t/>\n\t\t\t) : null}\n\n\t\t\t{/*\n\t\t\t\tRendered unconditionally, with the mount gate inside — the React\n\t\t\t\tshape of the source's `{#if open}`. The panel has to survive\n\t\t\t\t`open` flipping false for the length of its own exit, and its\n\t\t\t\tportal target has to be resolved before the commit that opens it.\n\t\t\t*/}\n\t\t\t<TimePickerPanel ref={setPanelNode} />\n\t\t</TimePickerReactContext.Provider>\n\t);\n});\n\nTimePicker.displayName = \"TimePicker\";\n","type":"registry:ui","target":"@ui/fancy/components/time-picker/TimePicker.tsx"},{"path":"react/src/components/time-picker/TimePickerPanel.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { Portal } from \"../../internals/Portal\";\nimport { useAnchorPosition } from \"../../internals/use-anchor-position\";\nimport { useDismissable } from \"../../internals/dismissable\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { anchored, originFor } from \"../../internals/motion/anchored\";\nimport { usePresence } from \"../../internals/motion/presence\";\nimport { useTimePickerContext } from \"./types\";\nimport \"./time-picker-panel.css\";\n\nexport interface TimePickerPanelProps {\n\t/** Additional CSS classes, merged onto the panel. */\n\tclassName?: string;\n}\n\n/**\n * `TimePicker`'s portalled listbox of slots.\n *\n * No focus trap here, unlike a dialog surface — a listbox popup is not a\n * dialog. Focus deliberately never leaves the trigger; the active row is only\n * ever communicated through `aria-activedescendant` on the button above, which\n * is why this panel carries `role=\"listbox\"` and `role=\"option\"` rows instead\n * of a plain menu, and why nothing here ever calls `.focus()`.\n *\n * ONE bidirectional transition, never a split in/out pair: `usePresence` owns\n * both directions on one clock, so a panel reopened mid-exit continues from\n * where it is instead of snapping to invisible first. `entering` is what tells\n * the transition which way it is going, and it arrives from the presence\n * handle rather than being guessed from a direction the runtime reports as\n * `\"both\"`.\n *\n * `TimePicker` renders this component unconditionally and the mount gate lives\n * here — the React shape of the source's `{#if open}` one level up, which is\n * also why `open` still has to arrive through the context. `data-state` is an\n * ORDINARY attribute (divergence D-2): the source writes it imperatively from\n * a transition event because its scheduler skips effects inside a closing\n * branch, and React re-renders an exiting surface normally. `inert` is never\n * written by hand either: `usePresence` sets it on the registered node for the\n * whole exit, which is what stops a row taking a click on its way out.\n */\nexport const TimePickerPanel = forwardRef<HTMLDivElement, TimePickerPanelProps>(\n\tfunction TimePickerPanel({ className }, forwardedRef) {\n\t\t// `TimePicker` only ever renders this component beneath its own\n\t\t// provider, so the context is always present by the time this runs —\n\t\t// there is no standalone-usage fallback to design for, the same as\n\t\t// `SelectPanel`.\n\t\tconst ctx = useTimePickerContext();\n\n\t\t// Convention C-1: the NODE, not a ref. The panel element is created by\n\t\t// `presence.mounted`, so a `useRef` + `[]`-deps effect would still be\n\t\t// holding `null` when the anchoring and dismiss effects fire.\n\t\tconst [panel, setPanelNode] = useElementRef<HTMLDivElement>();\n\n\t\tconst presence = usePresence(ctx.open);\n\n\t\t// The side and alignment the panel was ACTUALLY placed on — the\n\t\t// requested ones until `computePosition` flips or clamps them away from\n\t\t// a viewport edge. Seeded with the request rather than left undefined,\n\t\t// so the growth origin is already right on the first frame and only a\n\t\t// real flip ever has to correct it. The hook returns what the source\n\t\t// kept in two `$state` locals fed by `onPlacement`.\n\t\tconst { side: resolvedSide, align: resolvedAlign } = useAnchorPosition(panel, {\n\t\t\tanchor: () => ctx.triggerRef,\n\t\t\tside: \"bottom\",\n\t\t\talign: \"start\",\n\t\t\toffset: 4,\n\t\t});\n\n\t\t// `active: ctx.open` — a plain boolean where the source needed a getter.\n\t\t// The layer stays ON the stack for its whole exit and stops being TOP of\n\t\t// it the instant `open` flips, so a second Escape during the fade falls\n\t\t// through to whatever is underneath.\n\t\tuseDismissable(panel, {\n\t\t\tonDismiss: ctx.close,\n\t\t\texclude: () => [ctx.triggerRef],\n\t\t\tactive: ctx.open,\n\t\t});\n\n\t\t// Convention C-2: composed ABOVE any conditional. Calling this inside\n\t\t// the JSX branch below would be a conditional hook and would throw the\n\t\t// first time `mounted` flips.\n\t\tconst panelRef = useComposedRefs(\n\t\t\tsetPanelNode,\n\t\t\tforwardedRef,\n\t\t\tpresence.register(anchored, (entering) => ({ side: resolvedSide, entering }))\n\t\t);\n\n\t\tfunction rowClasses(index: number): string {\n\t\t\treturn cn(\n\t\t\t\t\"ft-time-picker-option flex cursor-pointer items-center justify-between gap-2 rounded-[6px] px-[10px] py-[6px] font-mono text-foreground select-none\",\n\t\t\t\t(ctx.isSelected(index) || ctx.isActive(index)) && \"bg-accent text-accent-foreground\"\n\t\t\t);\n\t\t}\n\n\t\t// The `Portal` stays ABOVE the mounted gate and the gate wraps its\n\t\t// CHILDREN. `usePortalTarget` resolves its container in a layout effect,\n\t\t// so a `Portal` mounting in the same commit as the panel renders nothing\n\t\t// on that pass — the registered node would then not exist when\n\t\t// `usePresence` looks for legs to start, and the entrance would be\n\t\t// silently skipped. Hoisting it resolves the container once, at this\n\t\t// component's own mount, which is `TimePicker`'s mount: every later open\n\t\t// attaches its node in the very commit presence is waiting for.\n\t\treturn (\n\t\t\t<Portal>\n\t\t\t\t{presence.mounted ? (\n\t\t\t\t\t<div\n\t\t\t\t\t\tref={panelRef}\n\t\t\t\t\t\tid={ctx.panelId}\n\t\t\t\t\t\trole=\"listbox\"\n\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\"ft-time-picker-panel border-border bg-popover text-popover-foreground flex max-h-[220px] w-max min-w-[100px] flex-col gap-[1px] overflow-y-auto rounded-[10px] border p-[5px] text-[12px] shadow-lg outline-none\",\n\t\t\t\t\t\t\tclassName\n\t\t\t\t\t\t)}\n\t\t\t\t\t\tdata-state={presence.surfaceState}\n\t\t\t\t\t\tdata-side={resolvedSide}\n\t\t\t\t\t\t// The REQUESTED alignment, not the resolved one, exactly\n\t\t\t\t\t\t// as the source publishes it — while `data-side` beside\n\t\t\t\t\t\t// it is the resolved value. The asymmetry is inherited\n\t\t\t\t\t\t// deliberately (fidelity over improvement); it is\n\t\t\t\t\t\t// invisible until a clamp actually moves the alignment,\n\t\t\t\t\t\t// which needs a real viewport edge.\n\t\t\t\t\t\tdata-align=\"start\"\n\t\t\t\t\t\t// `transform-origin` only — never `position`, `left` or\n\t\t\t\t\t\t// `top`, which `useAnchorPosition` writes imperatively on\n\t\t\t\t\t\t// this same element.\n\t\t\t\t\t\tstyle={{ transformOrigin: originFor(resolvedSide, resolvedAlign) }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{ctx.slots.length === 0 ? (\n\t\t\t\t\t\t\t/* Reachable when `min`/`max` exclude every generated slot — see the\n\t\t\t\t\t\t\t   README. A real row, not silently nothing: an empty floating panel\n\t\t\t\t\t\t\t   with no explanation is a worse failure than this one line. */\n\t\t\t\t\t\t\t<p className=\"text-muted-foreground px-[10px] py-[7px] text-[12px] italic\">\n\t\t\t\t\t\t\t\tNo times available.\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\tctx.slots.map((slot, index) => (\n\t\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t\t\tKeyboard activation for this row is not local: the trigger\n\t\t\t\t\t\t\t\t\tbutton owns every keydown (Enter/Space/arrow keys/Home/End)\n\t\t\t\t\t\t\t\t\tand drives selection through `aria-activedescendant`, per the\n\t\t\t\t\t\t\t\t\tARIA combobox-with-listbox-popup pattern. A row-level keydown\n\t\t\t\t\t\t\t\t\thandler would be dead code — focus never reaches a row to fire\n\t\t\t\t\t\t\t\t\tone — not a missing affordance.\n\t\t\t\t\t\t\t\t*/\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tkey={slot}\n\t\t\t\t\t\t\t\t\tid={ctx.optionId(index)}\n\t\t\t\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\t\t\t\ttabIndex={-1}\n\t\t\t\t\t\t\t\t\taria-selected={ctx.isSelected(index)}\n\t\t\t\t\t\t\t\t\tclassName={rowClasses(index)}\n\t\t\t\t\t\t\t\t\tonClick={() => ctx.commit(index)}\n\t\t\t\t\t\t\t\t\tonPointerEnter={() => ctx.setActive(index)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span>{ctx.labelFor(slot)}</span>\n\t\t\t\t\t\t\t\t\t{ctx.isSelected(index) ? (\n\t\t\t\t\t\t\t\t\t\t<span aria-hidden=\"true\" className=\"text-[var(--ft-field-accent)]\">\n\t\t\t\t\t\t\t\t\t\t\t✓\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t))\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t</Portal>\n\t\t);\n\t}\n);\n\nTimePickerPanel.displayName = \"TimePickerPanel\";\n","type":"registry:ui","target":"@ui/fancy/components/time-picker/TimePickerPanel.tsx"},{"path":"react/src/components/time-picker/index.ts","content":"\"use client\";\n\nexport { TimePicker } from \"./TimePicker\";\nexport type { TimePickerProps } from \"./TimePicker\";\n","type":"registry:ui","target":"@ui/fancy/components/time-picker/index.ts"},{"path":"react/src/components/time-picker/time-picker-panel.css","content":"/* The source's `TimePickerPanel.svelte` <style> block, verbatim. Already\n   anchored on the row's own class (`.ft-time-picker-option`, the first token of\n   each row's `cn()` call), so no port-added anchor class is needed here. The\n   custom property is re-declared locally with its full fallback chain on\n   purpose: a component must never depend on an app-level token existing. */\n.ft-time-picker-option {\n\t--ft-field-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/time-picker/time-picker-panel.css"},{"path":"react/src/components/time-picker/time-picker.css","content":"/* The source's `TimePicker.svelte` <style> block, verbatim. Already anchored on\n   the component's own root class (`.ft-time-picker-trigger`, the first token of\n   the trigger's `cn()` call), so no port-added anchor class is needed here. The\n   custom property is re-declared locally with its full fallback chain on\n   purpose: a component must never depend on an app-level token existing. */\n.ft-time-picker-trigger:focus-visible {\n\t--ft-field-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n\tborder-color: var(--ft-field-accent);\n\tbox-shadow: 0 0 0 3px color-mix(in oklab, var(--ft-field-accent) 25%, transparent);\n}\n","type":"registry:ui","target":"@ui/fancy/components/time-picker/time-picker.css"},{"path":"react/src/components/time-picker/time-utils.ts","content":"\"use client\";\n\n// Pure \"HH:mm\" helpers TimePicker builds its slot list and labels from. The\n// value contract is always 24-hour \"HH:mm\" — `hour12` only ever changes how a\n// slot is *displayed*, via `formatSlotLabel`, never the string the component\n// reads or writes.\n\nconst MINUTES_PER_DAY = 24 * 60;\n\n/** Parses \"HH:mm\" into total minutes since midnight. Returns `null` for anything malformed. */\nexport function toMinutes(value: string): number | null {\n\tconst match = /^(\\d{1,2}):(\\d{2})$/.exec(value);\n\tif (!match) return null;\n\tconst hours = Number(match[1]);\n\tconst minutes = Number(match[2]);\n\tif (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;\n\treturn hours * 60 + minutes;\n}\n\n/** Formats total minutes since midnight back into zero-padded \"HH:mm\". */\nexport function fromMinutes(totalMinutes: number): string {\n\tconst hours = Math.floor(totalMinutes / 60);\n\tconst minutes = totalMinutes % 60;\n\treturn `${String(hours).padStart(2, \"0\")}:${String(minutes).padStart(2, \"0\")}`;\n}\n\n/**\n * The full slot grid for `step`-minute increments, anchored at 00:00 —\n * 00:00, 00:{step}, 00:{2*step}, ... for as long as the slot start is still\n * within the day. A `step` that does not divide 60 or 1440 evenly (45, say)\n * is not special-cased: the loop simply stops once the next start would\n * reach or pass 24:00, which can leave a shorter-than-usual final gap before\n * midnight rather than inserting a partial slot — the defined behaviour the\n * brief asks for, documented here and in the README.\n */\nexport function generateSlots(step: number): string[] {\n\tconst safeStep = Math.max(1, Math.floor(step));\n\tconst slots: string[] = [];\n\tfor (let minutes = 0; minutes < MINUTES_PER_DAY; minutes += safeStep) {\n\t\tslots.push(fromMinutes(minutes));\n\t}\n\treturn slots;\n}\n\n/**\n * Keeps only the slots within `[min, max]` (either bound optional, both\n * inclusive). A bound that does not land exactly on a generated slot simply\n * excludes the slot straddling it, rather than inserting a synthetic one at\n * the bound itself — same \"defined, not silently magic\" behaviour as\n * `generateSlots`'s own step handling.\n */\nexport function filterByBounds(slots: string[], min?: string, max?: string): string[] {\n\tconst minMinutes = min ? toMinutes(min) : null;\n\tconst maxMinutes = max ? toMinutes(max) : null;\n\treturn slots.filter((slot) => {\n\t\tconst value = toMinutes(slot);\n\t\tif (value === null) return false;\n\t\tif (minMinutes !== null && value < minMinutes) return false;\n\t\tif (maxMinutes !== null && value > maxMinutes) return false;\n\t\treturn true;\n\t});\n}\n\n/**\n * Index of the slot to highlight when the panel opens: an exact match for\n * `value`, else the nearest slot at or after it, else the last slot (`value`\n * is later than every slot), else -1 (no slots at all). A `value` that does\n * not land on a generated boundary — set programmatically while `step`\n * doesn't divide it evenly — still resolves to a sensible neighbour instead\n * of leaving nothing highlighted.\n */\nexport function nearestIndex(slots: string[], value: string | null | undefined): number {\n\tif (slots.length === 0) return -1;\n\tconst target = value ? toMinutes(value) : null;\n\tif (target === null) return 0;\n\n\tlet best = -1;\n\tfor (let i = 0; i < slots.length; i++) {\n\t\t// The `!` is the package's `noUncheckedIndexedAccess` tax on a loop\n\t\t// already bounded by `slots.length` — a type-level edit only, the\n\t\t// runtime behaviour is the source's unchanged.\n\t\tconst slotMinutes = toMinutes(slots[i]!);\n\t\tif (slotMinutes === null) continue;\n\t\tif (slotMinutes >= target) {\n\t\t\tbest = i;\n\t\t\tbreak;\n\t\t}\n\t}\n\treturn best === -1 ? slots.length - 1 : best;\n}\n\n/** Formats \"HH:mm\" for display, honouring `hour12` and `locale`. The underlying value never changes shape. */\nexport function formatSlotLabel(value: string, hour12: boolean, locale?: string): string {\n\tconst minutes = toMinutes(value);\n\tif (minutes === null) return value;\n\t// An arbitrary, fixed reference date — only the time-of-day ever reaches\n\t// the formatter.\n\tconst reference = new Date(2000, 0, 1, Math.floor(minutes / 60), minutes % 60);\n\treturn new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\", hour12 }).format(\n\t\treference\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/time-picker/time-utils.ts"},{"path":"react/src/components/time-picker/types.ts","content":"\"use client\";\n\nimport { createInternalContext } from \"../../internals/dom/context\";\n\n/**\n * The contract between the `TimePicker` trigger and its portalled listbox\n * panel — the same split `Select`/`SelectPanel` use, for the same reason:\n * the root owns the slot list, the value and where the active (highlighted)\n * index sits, and the panel only ever reads that or reports back through\n * `setActive`/`commit`.\n */\nexport interface TimePickerContext {\n\t/** Whether the panel is open. Read by the panel's own presence clock, which\n\t *  needs it to tell an entrance from a departure. Also the panel's\n\t *  dismissable `active` gate: the instant this flips false the layer stops\n\t *  answering Escape, so a second Escape during the fade reaches whatever is\n\t *  underneath instead of being swallowed by a panel that is already\n\t *  leaving. */\n\treadonly open: boolean;\n\t/** The panel's own id — also what the trigger's `aria-controls` points at while open. */\n\treadonly panelId: string;\n\t/** The \"HH:mm\" slots to render as rows, in order. */\n\treadonly slots: string[];\n\t/** Index of the highlighted option, or -1 when nothing is active. */\n\treadonly activeIndex: number;\n\t/** Display label for a slot, honouring `hour12`/`locale`. */\n\tlabelFor(slot: string): string;\n\t/** The real trigger button — what the panel anchors against and excludes from its own outside-click check. */\n\treadonly triggerRef: HTMLElement | null;\n\t/** The id a given option row must carry — also what `aria-activedescendant` points at while it's active. */\n\toptionId(index: number): string;\n\t/** Whether the slot at `index` is the current selection. */\n\tisSelected(index: number): boolean;\n\t/** Whether the slot at `index` is the highlighted (`aria-activedescendant`) one. */\n\tisActive(index: number): boolean;\n\t/** Highlights `index` without selecting it — a pointer hover, not a commit. */\n\tsetActive(index: number): void;\n\t/** Selects the slot at `index` and closes the panel, the same way Enter does. */\n\tcommit(index: number): void;\n\t/** Closes the panel without changing the value — what Escape and an outside click do. */\n\tclose(): void;\n}\n\n/**\n * The React counterpart of the source's `TIME_PICKER_KEY` symbol: a context\n * object plus its readers, per the internals contract's naming table\n * (`FOO_KEY` → `FooReactContext` + `useFoo()`).\n *\n * Not re-exported from this folder's `index.ts`, because the source barrel\n * does not export `TIME_PICKER_KEY` either — the trigger/panel wiring is\n * private to the pair.\n */\nexport const TimePickerReactContext = createInternalContext<TimePickerContext>(\n\t\"TimePicker context\"\n);\n\n/** Reads the surrounding `TimePicker`'s context. Throws outside one, which is\n *  the compound-component contract: `TimePickerPanel` is only ever rendered by\n *  `TimePicker`, so there is no standalone-usage fallback to design for — the\n *  same as the source's `getContext(TIME_PICKER_KEY)` with no fallback branch. */\nexport const useTimePickerContext = TimePickerReactContext.useRequired;\n","type":"registry:ui","target":"@ui/fancy/components/time-picker/types.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/time-picker?framework=react"}}