{
  "$schema": "https://blode.co/ui/schema/registry-item.json",
  "name": "time-picker",
  "title": "Time Picker",
  "author": "Matthew Blode",
  "description": "A select of times at a fixed interval, formatted for the locale.",
  "registryDependencies": ["select"],
  "files": [
    {
      "path": "ui/time-picker.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\n\nconst MINUTES_PER_DAY = 24 * 60;\n\n/** Parses `\"HH:mm\"` into minutes past midnight. Returns null when unparseable. */\nconst toMinutes = (value: string): number | null => {\n  const match = /^(?<hours>\\d{1,2}):(?<minutes>\\d{2})$/u.exec(value.trim());\n\n  if (!match?.groups) {\n    return null;\n  }\n\n  const hours = Number(match.groups.hours);\n  const minutes = Number(match.groups.minutes);\n\n  if (hours > 23 || minutes > 59) {\n    return null;\n  }\n\n  return hours * 60 + minutes;\n};\n\n/** Formats minutes past midnight back into a locale-independent `\"HH:mm\"`. */\nconst toTimeString = (minutes: number): string => {\n  const wrapped = ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;\n\n  return `${String(Math.floor(wrapped / 60)).padStart(2, \"0\")}:${String(wrapped % 60).padStart(2, \"0\")}`;\n};\n\nexport interface TimePickerProps {\n  /** Class names merged onto the trigger. */\n  className?: string;\n  /** Disables the control. */\n  disabled?: boolean;\n  /**\n   * Force a 12- or 24-hour display. Defaults to whatever the locale itself\n   * prefers, so an `en-GB` user is not shown AM/PM.\n   */\n  hour12?: boolean;\n  /** Id applied to the trigger, for a `FieldLabel htmlFor`. */\n  id?: string;\n  /**\n   * BCP 47 locale used for display only. Defaults to `navigator.language`. Pass\n   * it explicitly when the control is server-rendered, so the server and the\n   * client format the same string.\n   */\n  locale?: string;\n  /** Latest selectable time as `\"HH:mm\"`, inclusive. */\n  max?: string;\n  /** Earliest selectable time as `\"HH:mm\"`, inclusive. */\n  min?: string;\n  /** Called with the new `\"HH:mm\"` value. */\n  onValueChange?: (value: string) => void;\n  /** Shown on the trigger while no time is selected. */\n  placeholder?: string;\n  /** Field height and text size. `\"sm\"` matches a 40px icon button. */\n  size?: \"default\" | \"sm\";\n  /** Minutes between options. */\n  step?: number;\n  /** Selected time as a 24-hour `\"HH:mm\"` string. */\n  value?: string;\n}\n\n/**\n * Resolving the host locale during render makes the server and the client\n * disagree — Node's ICU default is rarely the browser's — so every\n * server-rendered time is a hydration mismatch. The probe is deferred until\n * after hydration; until then the raw 24-hour value is rendered, which is\n * identical on both sides. Pass `locale` (or `hour12`) to skip the deferral\n * and get a stable string from the first paint.\n */\nconst subscribeToHydration = () => () => {\n  // never changes after mount\n};\n\nconst useIsHydrated = () =>\n  React.useSyncExternalStore(\n    subscribeToHydration,\n    () => true,\n    () => false,\n  );\n\n/**\n * `navigator.language`, not `Intl`'s default locale. Chrome derives the latter\n * from the browser's UI language, which on macOS routinely disagrees with the\n * language the user actually asked pages to be in: a browser reporting\n * `en-US` still resolves `Intl.DateTimeFormat()` to `en-GB`, and renders 24-hour\n * times to someone who has only ever asked for American English. Returns\n * undefined off the browser so `Intl` keeps its own default there.\n */\nconst hostLocale = (): string | undefined =>\n  typeof navigator === \"undefined\" ? undefined : navigator.languages?.[0] || navigator.language;\n\nconst TimePicker = ({\n  className,\n  disabled = false,\n  hour12,\n  id,\n  locale,\n  max = \"23:45\",\n  min = \"00:00\",\n  onValueChange,\n  placeholder = \"Select a time\",\n  size = \"default\",\n  step = 15,\n  value,\n}: TimePickerProps) => {\n  const isHydrated = useIsHydrated();\n  const canLocalize = locale !== undefined || isHydrated;\n\n  const formatter = React.useMemo(() => {\n    if (!canLocalize) {\n      return null;\n    }\n\n    const resolved = locale ?? hostLocale();\n    const resolvedHour12 =\n      hour12 ??\n      new Intl.DateTimeFormat(resolved, { hour: \"numeric\" }).resolvedOptions().hour12 ??\n      false;\n\n    return new Intl.DateTimeFormat(resolved, {\n      hour: \"numeric\",\n      hour12: resolvedHour12,\n      minute: \"2-digit\",\n    });\n  }, [canLocalize, hour12, locale]);\n\n  const format = React.useCallback(\n    (time: string) => {\n      const minutes = toMinutes(time);\n\n      if (minutes === null || formatter === null) {\n        return time;\n      }\n\n      return formatter.format(new Date(2000, 0, 1, Math.floor(minutes / 60), minutes % 60));\n    },\n    [formatter],\n  );\n\n  const options = React.useMemo(() => {\n    const start = toMinutes(min) ?? 0;\n    const end = toMinutes(max) ?? MINUTES_PER_DAY - 1;\n    const interval = Math.max(1, Math.round(step));\n    const minutes: number[] = [];\n\n    for (let minute = start; minute <= end; minute += interval) {\n      minutes.push(minute);\n    }\n\n    // A caller's value that is off the step grid — a time saved before `step`\n    // changed, say — is still a real answer, so it joins the list rather than\n    // silently disappearing from the trigger.\n    const current = value === undefined ? null : toMinutes(value);\n\n    if (current !== null && !minutes.includes(current)) {\n      minutes.push(current);\n      minutes.sort((a, b) => a - b);\n    }\n\n    return minutes.map(toTimeString);\n  }, [max, min, step, value]);\n\n  return (\n    <Select disabled={disabled} onValueChange={onValueChange} value={value}>\n      {/* `font-mono` is what actually swaps the family here and on the items\n          below: `tabular-figures` sets one too, but the `font-sans` baked into\n          the trigger and the item outranks it, and only `font-mono` is in a\n          `cn` merge group that drops it. */}\n      <SelectTrigger\n        className={cn(\"font-mono tabular-figures\", size === \"sm\" && \"pr-2.5 text-sm\", className)}\n        id={id}\n        size={size}\n      >\n        <SelectValue placeholder={placeholder}>\n          {(selected: string | null) => (selected ? format(selected) : placeholder)}\n        </SelectValue>\n      </SelectTrigger>\n      <SelectContent>\n        {options.map((option) => (\n          <SelectItem\n            className={cn(\"font-mono tabular-figures\", size === \"sm\" && \"text-sm\")}\n            key={option}\n            value={option}\n          >\n            {format(option)}\n          </SelectItem>\n        ))}\n      </SelectContent>\n    </Select>\n  );\n};\n\nexport { TimePicker };\n",
      "type": "registry:ui",
      "target": ""
    }
  ],
  "type": "registry:ui"
}
