{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "time-field",
  "title": "Time Field",
  "description": "Segmented hour, minute and AM/PM spinbuttons with typeahead.",
  "registryDependencies": [
    "utils",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/default/ui/time-field.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/registry/default/lib/utils\"\nimport {\n  MIDNIGHT,\n  formatTimeOfDay,\n  parseTimeOfDay,\n  segmentLabel,\n  segmentRange,\n  segmentText,\n  stepSegment,\n  writeSegment,\n  readSegment,\n  type HourCycle,\n  type TimeOfDay,\n  type TimeSegment,\n} from \"@/registry/default/lib/time-segments\"\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\n\nconst TYPEAHEAD_RESET_MS = 1000\n\ntype TypedDigits = { segment: TimeSegment; digits: string; typedAtMs: number }\n\nfunction TimeField({\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  hourCycle = 24,\n  minuteStep = 1,\n  name,\n  disabled,\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (value: string) => void\n  hourCycle?: HourCycle\n  minuteStep?: number\n  name?: string\n  disabled?: boolean\n}) {\n  const { value: timeValue, setValue: setTimeValue } = useControllableState({\n    value,\n    defaultValue,\n    onChange: onValueChange,\n  })\n  const segmentRefs = React.useRef<(HTMLSpanElement | null)[]>([])\n  const typedDigitsRef = React.useRef<TypedDigits | null>(null)\n  const time = parseTimeOfDay(timeValue)\n  const segments: TimeSegment[] =\n    hourCycle === 12 ? [\"hour\", \"minute\", \"period\"] : [\"hour\", \"minute\"]\n\n  function commit(next: TimeOfDay) {\n    setTimeValue(formatTimeOfDay(next))\n  }\n\n  function focusSegment(index: number) {\n    segmentRefs.current[index]?.focus()\n  }\n\n  function typeDigit(\n    segment: TimeSegment,\n    index: number,\n    digit: string,\n    typedAtMs: number\n  ) {\n    const { min, max } = segmentRange(segment, hourCycle)\n    const previous = typedDigitsRef.current\n    const isContinuing =\n      previous?.segment === segment &&\n      typedAtMs - previous.typedAtMs < TYPEAHEAD_RESET_MS\n    const combined = isContinuing ? previous.digits + digit : digit\n    const digits = Number(combined) > max ? digit : combined\n    const typedValue = Number(digits)\n    const isComplete = digits.length === 2 || typedValue * 10 > max\n\n    typedDigitsRef.current = isComplete ? null : { segment, digits, typedAtMs }\n    if (typedValue >= min)\n      commit(writeSegment(time ?? MIDNIGHT, segment, typedValue, hourCycle))\n    if (isComplete) focusSegment(index + 1)\n  }\n\n  function typePeriod(key: string) {\n    const periodIndex = { a: 0, p: 1 }[key.toLowerCase()]\n    if (periodIndex === undefined) return\n    commit(writeSegment(time ?? MIDNIGHT, \"period\", periodIndex, hourCycle))\n  }\n\n  function handleKeyDown(\n    segment: TimeSegment,\n    index: number,\n    event: React.KeyboardEvent<HTMLSpanElement>\n  ) {\n    const current = time ?? MIDNIGHT\n    const step = segment === \"minute\" ? minuteStep : 1\n    const { min, max } = segmentRange(segment, hourCycle)\n\n    switch (event.key) {\n      case \"ArrowUp\":\n      case \"ArrowDown\":\n        event.preventDefault()\n        commit(\n          stepSegment(\n            current,\n            segment,\n            event.key === \"ArrowUp\" ? step : -step,\n            hourCycle\n          )\n        )\n        return\n      case \"Home\":\n      case \"End\":\n        event.preventDefault()\n        commit(\n          writeSegment(\n            current,\n            segment,\n            event.key === \"Home\" ? min : max,\n            hourCycle\n          )\n        )\n        return\n      case \"ArrowLeft\":\n      case \"ArrowRight\":\n        event.preventDefault()\n        focusSegment(event.key === \"ArrowLeft\" ? index - 1 : index + 1)\n        return\n      case \"Backspace\":\n      case \"Delete\":\n        event.preventDefault()\n        setTimeValue(\"\")\n        return\n    }\n\n    if (segment === \"period\") return typePeriod(event.key)\n    if (/^\\d$/.test(event.key)) {\n      event.preventDefault()\n      typeDigit(segment, index, event.key, event.timeStamp)\n    }\n  }\n\n  return (\n    <div\n      role=\"group\"\n      data-slot=\"time-field\"\n      data-disabled={disabled || undefined}\n      className={cn(\n        \"inline-flex h-9 w-fit items-center rounded-md border border-input bg-transparent px-2 font-mono text-sm tabular-nums shadow-xs transition-[color,box-shadow] dark:bg-input/30\",\n        \"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50\",\n        \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      {segments.map((segment, index) => {\n        const { min, max } = segmentRange(segment, hourCycle)\n        const text = segmentText(time, segment, hourCycle)\n\n        return (\n          <React.Fragment key={segment}>\n            {segment === \"minute\" && (\n              <span aria-hidden className=\"text-muted-foreground\">\n                :\n              </span>\n            )}\n            <span\n              ref={(node) => {\n                segmentRefs.current[index] = node\n              }}\n              role=\"spinbutton\"\n              tabIndex={disabled ? -1 : 0}\n              data-slot=\"time-field-segment\"\n              data-placeholder={time ? undefined : \"\"}\n              aria-label={segmentLabel(segment)}\n              aria-valuemin={min}\n              aria-valuemax={max}\n              aria-valuenow={\n                time ? readSegment(time, segment, hourCycle) : undefined\n              }\n              aria-valuetext={time ? text : \"Empty\"}\n              onKeyDown={(event) => handleKeyDown(segment, index, event)}\n              onBlur={() => {\n                typedDigitsRef.current = null\n              }}\n              className={cn(\n                \"rounded-sm px-0.5 outline-none select-none focus:bg-accent focus:text-accent-foreground data-placeholder:text-muted-foreground\",\n                segment === \"period\" && \"ml-1\"\n              )}\n            >\n              {text}\n            </span>\n          </React.Fragment>\n        )\n      })}\n      {name && <input type=\"hidden\" name={name} value={timeValue} />}\n    </div>\n  )\n}\n\nexport { TimeField }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/time-segments.ts",
      "content": "export type TimeSegment = \"hour\" | \"minute\" | \"period\"\nexport type HourCycle = 12 | 24\nexport type TimeOfDay = { hours: number; minutes: number }\n\nexport const MIDNIGHT: TimeOfDay = { hours: 0, minutes: 0 }\n\nconst SEGMENT_LABEL: Record<TimeSegment, string> = {\n  hour: \"Hours\",\n  minute: \"Minutes\",\n  period: \"AM/PM\",\n}\n\nfunction padTwo(value: number) {\n  return String(value).padStart(2, \"0\")\n}\n\nexport function segmentLabel(segment: TimeSegment) {\n  return SEGMENT_LABEL[segment]\n}\n\nexport function segmentRange(segment: TimeSegment, hourCycle: HourCycle) {\n  switch (segment) {\n    case \"hour\":\n      return hourCycle === 12 ? { min: 1, max: 12 } : { min: 0, max: 23 }\n    case \"minute\":\n      return { min: 0, max: 59 }\n    case \"period\":\n      return { min: 0, max: 1 }\n  }\n}\n\nexport function parseTimeOfDay(value: string): TimeOfDay | null {\n  const match = /^(\\d{1,2}):(\\d{2})$/.exec(value)\n  if (!match) return null\n  const hours = Number(match[1])\n  const minutes = Number(match[2])\n  if (hours > 23 || minutes > 59) return null\n  return { hours, minutes }\n}\n\nexport function formatTimeOfDay({ hours, minutes }: TimeOfDay) {\n  return `${padTwo(hours)}:${padTwo(minutes)}`\n}\n\nexport function readSegment(\n  time: TimeOfDay,\n  segment: TimeSegment,\n  hourCycle: HourCycle\n) {\n  switch (segment) {\n    case \"hour\":\n      return hourCycle === 12 ? time.hours % 12 || 12 : time.hours\n    case \"minute\":\n      return time.minutes\n    case \"period\":\n      return time.hours >= 12 ? 1 : 0\n  }\n}\n\nexport function writeSegment(\n  time: TimeOfDay,\n  segment: TimeSegment,\n  segmentValue: number,\n  hourCycle: HourCycle\n): TimeOfDay {\n  const isAfternoon = time.hours >= 12\n  switch (segment) {\n    case \"hour\":\n      if (hourCycle === 24) return { ...time, hours: segmentValue }\n      return { ...time, hours: (segmentValue % 12) + (isAfternoon ? 12 : 0) }\n    case \"minute\":\n      return { ...time, minutes: segmentValue }\n    case \"period\":\n      return { ...time, hours: (time.hours % 12) + segmentValue * 12 }\n  }\n}\n\nexport function stepSegment(\n  time: TimeOfDay,\n  segment: TimeSegment,\n  delta: number,\n  hourCycle: HourCycle\n) {\n  const { min, max } = segmentRange(segment, hourCycle)\n  const size = max - min + 1\n  const current = readSegment(time, segment, hourCycle)\n  const wrapped = min + ((((current - min + delta) % size) + size) % size)\n  return writeSegment(time, segment, wrapped, hourCycle)\n}\n\nexport function segmentText(\n  time: TimeOfDay | null,\n  segment: TimeSegment,\n  hourCycle: HourCycle\n) {\n  if (segment === \"period\") {\n    if (!time) return \"--\"\n    return readSegment(time, segment, hourCycle) === 1 ? \"PM\" : \"AM\"\n  }\n  if (!time) return \"--\"\n  return padTwo(readSegment(time, segment, hourCycle))\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}