{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"time-picker","type":"registry:ui","title":"Time Picker","description":"A time picked from a list of steps or typed exactly, shown the locale's way, stored as HH:MM.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/button.json","https://dashral.com/r/field.json","https://dashral.com/r/input.json","https://dashral.com/r/popover.json"],"files":[{"path":"registry/dashral/time-picker.tsx","type":"registry:file","target":"components/dashral/time-picker.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { CheckIcon, ClockIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { FieldWrapper, splitFieldProps, useFieldControl, type FieldClassNames, type FieldControlProps, type FieldOwnProps } from \"./field\"\nimport { Button } from \"./button\"\nimport { Input } from \"./input\"\nimport { PopoverContent, PopoverRoot, PopoverTrigger } from \"./popover\"\n\ntype TimePickerProps = FieldOwnProps & {\n  /** The time as \"HH:MM\", 24-hour (\"14:30\"), whatever the display. Controlled; or set defaultValue. */\n  value?: string\n  defaultValue?: string\n  onValueChange?: (time: string | undefined) => void\n  /** Minutes between the times in the list. */\n  step?: number\n  /** The earliest and latest times offered, \"HH:MM\": office hours, a booking window. */\n  min?: string\n  max?: string\n  /** 12-hour (2:30 PM) or 24-hour (14:30) display; the locale's habit when left out. */\n  hour12?: boolean\n  placeholder?: string\n  /** BCP 47 locale for the display, e.g. \"id-ID\". */\n  locale?: string\n  disabled?: boolean\n  required?: boolean\n  id?: string\n  /** The trigger's classes. */\n  className?: string\n  \"aria-label\"?: string\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"]\n  classNames?: FieldClassNames & { trigger?: string; content?: string; option?: string }\n}\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\n/** The times the list offers, from min to max every step minutes. */\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 * A time, picked from a list (every 30 minutes, or your step, between min and max) or typed exactly into the field at\n * its top. Shown the locale's way (2:30 PM or 14:30); the value is always \"HH:MM\", 24-hour, ready to store. Give it a\n * label, description or error and it becomes a whole field.\n */\nfunction TimePicker(props: TimePickerProps) {\n  const [field, { required: _required, ...control }] = splitFieldProps(props)\n  return (\n    <FieldWrapper {...field}>\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 { t, locale: contextLocale } = useLocale()\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\n  function select(time: string | undefined) {\n    setInternal(time)\n    onValueChange?.(time)\n    setOpen(false)\n  }\n\n  // Opening, the list starts at the picked time (or the nearest one after it), not at midnight.\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\" })\n  }\n\n  return (\n    <PopoverRoot\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next)\n        if (next) setTyped(\"\")\n      }}\n    >\n      <PopoverTrigger 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            // Looks like the other fields (Input, Select), not like a button.\n            \"border-input bg-transparent hover:bg-transparent focus-visible:border-ring focus-visible:ring-ring/15 motion-safe:enabled:not-aria-busy:active:scale-100\",\n            current ? \"hover:text-foreground\" : \"text-muted-foreground hover:text-muted-foreground\",\n            className,\n            classNames?.trigger\n          )}\n        >\n          <ClockIcon aria-hidden />\n          {current ? show(current) : placeholder}\n        </Button>\n      </PopoverTrigger>\n      <PopoverContent\n        className={cn(\"w-48 p-0\", classNames?.content)}\n        align=\"start\"\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        {/* An exact time, typed: Enter picks it. */}\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        <div ref={list} role=\"listbox\" aria-label={ariaLabel ?? t(\"pickTime\")} onKeyDown={onListKey} className=\"max-h-60 overflow-y-auto p-1\">\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                className={cn(\n                  \"flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm tabular-nums outline-none hover:bg-accent focus-visible:bg-accent aria-selected:font-medium\",\n                  classNames?.option\n                )}\n              >\n                {show(time)}\n                {picked && <CheckIcon aria-hidden className=\"size-4\" />}\n              </button>\n            )\n          })}\n        </div>\n      </PopoverContent>\n    </PopoverRoot>\n  )\n}\n\nexport { TimePicker, type TimePickerProps }\n"}]}