{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-time-picker","type":"registry:component","title":"Time Picker (Motion)","description":"TimePicker with motion: the list springs out of its trigger, one highlight glides over the times, the tick draws, and a new time rises into the trigger.","dependencies":["lucide-react","motion","radix-ui"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/lib-portal.json","https://dashral.com/r/motion-field.json","https://dashral.com/r/motion-hover.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/field.json","https://dashral.com/r/time-picker.json","https://dashral.com/r/motion-button.json","https://dashral.com/r/motion-input.json"],"files":[{"path":"registry/dashral/motion/time-picker.tsx","type":"registry:file","target":"components/dashral/motion/time-picker.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Popover as PopoverPrimitive } from \"radix-ui\"\nimport { ClockIcon } from \"lucide-react\"\nimport { AnimatePresence, LayoutGroup, motion } from \"motion/react\"\nimport { FieldWrapper, splitFieldProps, useFieldControl, type FieldControlProps, type FieldOwnProps } from \"../field\"\nimport type { TimePickerProps } from \"../time-picker\"\nimport { useLocale } from \"../lib/locale\"\nimport { cn } from \"../lib/utils\"\nimport { usePortalContainer } from \"../lib/portal\"\n\nimport { useHoverPill } from \"../lib/motion-hover\"\nimport { motionMessages } from \"../lib/motion-field\"\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, ease, spring } from \"../lib/motion-tokens\"\nimport { Button } from \"./button\"\nimport { Input } from \"./input\"\n\nexport type { TimePickerProps } from \"../time-picker\"\n\n/** \"14:30\" to minutes past midnight, or null when it isn't a time. */\nfunction toMinutes(time: string | undefined) {\n  const match = time?.match(/^(\\d{1,2}):(\\d{2})$/)\n  if (!match) return null\n  const hours = Number(match[1])\n  const minutes = Number(match[2])\n  return hours < 24 && minutes < 60 ? hours * 60 + minutes : null\n}\nconst toTime = (minutes: number) => `${String(Math.floor(minutes / 60)).padStart(2, \"0\")}:${String(minutes % 60).padStart(2, \"0\")}`\n\nfunction slotsFor(step: number, min?: string, max?: string) {\n  const from = toMinutes(min) ?? 0\n  const to = toMinutes(max) ?? 24 * 60 - 1\n  const slots: string[] = []\n  for (let at = from; at <= to; at += Math.max(1, step)) slots.push(toTime(at))\n  return slots\n}\n\n/**\n * The kit's TimePicker with motion: the same step, min, max, display and field props. The list springs out of its\n * trigger with the picked time in view; one highlight glides from time to time after the pointer or the arrow keys\n * without a blink; the picked time's tick draws itself; and a new time rises into the trigger over the old. With reduced\n * motion it only fades.\n */\nexport function TimePicker(props: TimePickerProps) {\n  const [field, { required: _required, ...control }] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field} renderMessages={motionMessages}>\n      <TimePickerControl {...control} />\n    </FieldWrapper>\n  )\n}\n\nfunction TimePickerControl({\n  value,\n  defaultValue,\n  onValueChange,\n  step = 30,\n  min,\n  max,\n  hour12,\n  placeholder: placeholderProp,\n  locale: localeProp,\n  classNames,\n  className,\n  \"aria-label\": ariaLabel,\n  ...rest\n}: Omit<TimePickerProps, keyof FieldOwnProps | \"required\">) {\n  const portalContainer = usePortalContainer()\n  const { t, locale: contextLocale } = useLocale()\n  const reduced = useReducedMotion()\n  const locale = localeProp ?? contextLocale\n  const placeholder = placeholderProp ?? t(\"pickTime\")\n  const { id, disabled, ...aria } = useFieldControl<FieldControlProps>({ id: rest.id, disabled: rest.disabled, \"aria-invalid\": rest[\"aria-invalid\"] })\n  const [open, setOpen] = React.useState(false)\n  const [internal, setInternal] = React.useState<string | undefined>(defaultValue)\n  const current = value === undefined && onValueChange === undefined ? internal : value\n  const [typed, setTyped] = React.useState(\"\")\n  const slots = React.useMemo(() => slotsFor(step, min, max), [step, min, max])\n  const format = React.useMemo(() => new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\", hour12, timeZone: \"UTC\" }), [locale, hour12])\n  const show = (time: string) => format.format(new Date(Date.UTC(2000, 0, 1, 0, toMinutes(time) ?? 0)))\n  const list = React.useRef<HTMLDivElement>(null)\n  const pill = useHoverPill<string>(reduced)\n  const group = React.useId()\n\n  function select(time: string | undefined) {\n    setInternal(time)\n    onValueChange?.(time)\n    setOpen(false)\n  }\n\n  function focusCurrent() {\n    const node = list.current\n    if (!node) return\n    const target = toMinutes(current) ?? -1\n    const options = [...node.querySelectorAll<HTMLButtonElement>(\"[role=option]\")]\n    const option = options.find((el) => (toMinutes(el.dataset.value) ?? 0) >= target) ?? options[0]\n    option?.scrollIntoView({ block: \"center\" })\n    option?.focus({ preventScroll: true })\n  }\n\n  function onListKey(event: React.KeyboardEvent<HTMLDivElement>) {\n    const options = [...event.currentTarget.querySelectorAll<HTMLButtonElement>(\"[role=option]\")]\n    const at = options.indexOf(document.activeElement as HTMLButtonElement)\n    const moves: Record<string, number> = { ArrowDown: 1, ArrowUp: -1, PageDown: 6, PageUp: -6 }\n    let next = -1\n    if (event.key in moves) next = Math.min(options.length - 1, Math.max(0, (at < 0 ? -1 : at) + moves[event.key]!))\n    else if (event.key === \"Home\") next = 0\n    else if (event.key === \"End\") next = options.length - 1\n    if (next < 0) return\n    event.preventDefault()\n    options[next]?.focus()\n    options[next]?.scrollIntoView({ block: \"nearest\", behavior: reduced ? \"auto\" : \"smooth\" })\n  }\n\n  return (\n    <PopoverPrimitive.Root\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next)\n        if (next) setTyped(\"\")\n        else pill.clear()\n      }}\n    >\n      <PopoverPrimitive.Trigger asChild>\n        <Button\n          id={id}\n          variant=\"outline\"\n          disabled={disabled}\n          aria-label={ariaLabel}\n          aria-describedby={aria[\"aria-describedby\"]}\n          aria-invalid={aria[\"aria-invalid\"]}\n          className={cn(\n            \"w-full justify-start font-normal tabular-nums aria-invalid:border-destructive\",\n            \"border-input bg-transparent hover:bg-transparent focus-visible:border-ring focus-visible:ring-ring/15\",\n            current ? \"hover:text-foreground\" : \"text-muted-foreground hover:text-muted-foreground\",\n            className,\n            classNames?.trigger\n          )}\n        >\n          <ClockIcon aria-hidden />\n          {/* A new time rises in over the old. */}\n          <span className=\"relative grid overflow-hidden\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.span\n                key={current ?? \"none\"}\n                className=\"col-start-1 row-start-1\"\n                initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={reduced ? { opacity: 0 } : { opacity: 0, y: -8, transition: { duration: duration.fast, ease: ease.exit } }}\n                transition={{ duration: duration.standard, ease: ease.enter }}\n              >\n                {current ? show(current) : placeholder}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        </Button>\n      </PopoverPrimitive.Trigger>\n      <AnimatePresence>\n        {open && (\n          <PopoverPrimitive.Portal forceMount container={portalContainer}>\n            <PopoverPrimitive.Content\n              forceMount\n              asChild\n              align=\"start\"\n              sideOffset={4}\n              collisionPadding={8}\n              onOpenAutoFocus={(event) => {\n                // The list takes the focus, at the picked time, once it's laid out.\n                event.preventDefault()\n                requestAnimationFrame(focusCurrent)\n              }}\n            >\n              <motion.div\n                className={cn(\"z-50 w-48 rounded-md border bg-popover text-popover-foreground shadow-md outline-hidden\", classNames?.content)}\n                style={{ transformOrigin: \"var(--radix-popover-content-transform-origin)\" }}\n                initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: -6 }}\n                animate={{ opacity: 1, scale: 1, y: 0 }}\n                exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: -6, transition: { duration: duration.fast, ease: ease.exit } }}\n                transition={reduced ? { duration: duration.fast } : { ...spring.appear, opacity: { duration: duration.fast, ease: ease.enter } }}\n              >\n                <form\n                  className=\"border-b p-2\"\n                  onSubmit={(event) => {\n                    event.preventDefault()\n                    if (toMinutes(typed) !== null) select(typed)\n                  }}\n                >\n                  <Input aria-label={t(\"typeTime\")} format=\"time\" placeholder=\"HH:MM\" value={typed} onChange={(e) => setTyped(e.target.value)} className=\"h-8\" />\n                </form>\n                <LayoutGroup id={group}>\n                  {/* The list, not each time, is the stacking context: the highlight stays behind every time's text. */}\n                  <div\n                    ref={list}\n                    role=\"listbox\"\n                    aria-label={ariaLabel ?? t(\"pickTime\")}\n                    onKeyDown={onListKey}\n                    onPointerLeave={pill.leave}\n                    className=\"relative isolate max-h-60 overflow-y-auto p-1\"\n                  >\n                    {slots.map((time) => {\n                      const picked = time === current\n                      return (\n                        <button\n                          key={time}\n                          type=\"button\"\n                          role=\"option\"\n                          aria-selected={picked}\n                          data-value={time}\n                          tabIndex={picked || (!current && time === slots[0]) ? 0 : -1}\n                          onClick={() => select(time)}\n                          onPointerEnter={() => pill.enter(time)}\n                          onFocus={() => pill.enter(time)}\n                          className={cn(\n                            \"relative flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm tabular-nums outline-none aria-selected:font-medium\",\n                            classNames?.option\n                          )}\n                        >\n                          {pill.hot === time && (\n                            <motion.span\n                              layoutId=\"hot\"\n                              aria-hidden\n                              className=\"absolute inset-0 -z-10 rounded-sm bg-accent\"\n                              style={{ opacity: pill.opacity }}\n                              transition={reduced ? { duration: 0 } : spring.move}\n                            />\n                          )}\n                          {show(time)}\n                          {picked && (\n                            <svg viewBox=\"0 0 24 24\" aria-hidden className=\"size-4 fill-none stroke-current stroke-2 [stroke-linecap:round] [stroke-linejoin:round]\">\n                              <motion.path\n                                d=\"M4 12l5 5L20 6\"\n                                initial={reduced ? false : { pathLength: 0 }}\n                                animate={{ pathLength: 1 }}\n                                transition={{ duration: duration.standard, ease: ease.enter, delay: 0.1 }}\n                              />\n                            </svg>\n                          )}\n                        </button>\n                      )\n                    })}\n                  </div>\n                </LayoutGroup>\n              </motion.div>\n            </PopoverPrimitive.Content>\n          </PopoverPrimitive.Portal>\n        )}\n      </AnimatePresence>\n    </PopoverPrimitive.Root>\n  )\n}\n"}]}