{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "simple-time-picker",
  "title": "Simple Time Picker",
  "author": "@fma-ui/ui",
  "description": "A button showing a time that opens a popover with columns for hours, minutes, seconds and AM/PM. Values are `@internationalized/date` `Time` objects (`value`/`defaultValue`/`onChange`), like the registry's DateField. `granularity` (\"hour\", \"minute\", \"second\"; default \"minute\") picks the columns and `hourCycle={12}` adds AM/PM. `minValue`/`maxValue` disable out-of-range options and clamp picks to the nearest allowed time. Each column is a react-aria `ListBox`: arrow keys and typing move within it, Tab moves between columns, Escape closes. For typed entry use `DateField` with `granularity=\"minute\"` or react-aria's `TimeField`.",
  "dependencies": [
    "@internationalized/date@^3.12.4",
    "cn@^0.4.0",
    "lucide-react@^1.52.0",
    "react-aria-components@^1.21.1"
  ],
  "files": [
    {
      "path": "src/core/simple-time-picker/simple-time-picker.tsx",
      "content": "\"use client\";\n\nimport { Time } from \"@internationalized/date\";\nimport { cn } from \"cn\";\nimport { CheckIcon, ChevronDownIcon, ClockIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport {\n  Button,\n  Dialog,\n  DialogTrigger,\n  type Key,\n  ListBox,\n  ListBoxItem,\n  Popover,\n} from \"react-aria-components\";\n\ntype Granularity = \"hour\" | \"minute\" | \"second\";\n\ntype SimpleTimePickerProps = {\n  /** The selected time (controlled). */\n  value?: Time | null;\n  /** The initial time (uncontrolled). */\n  defaultValue?: Time | null;\n  onChange?: (value: Time) => void;\n  /** Earliest selectable time; picks before it are clamped to it. */\n  minValue?: Time;\n  /** Latest selectable time; picks after it are clamped to it. */\n  maxValue?: Time;\n  /** 12 shows an AM/PM column. Defaults to 24. */\n  hourCycle?: 12 | 24;\n  /** The smallest unit offered. Defaults to \"minute\". */\n  granularity?: Granularity;\n  isDisabled?: boolean;\n  /** Names the trigger for assistive tech. Defaults to \"Time\". */\n  \"aria-label\"?: string;\n  className?: string;\n};\n\ntype Option = { id: number; label: string; isDisabled: boolean };\n\nconst pad = (n: number) => n.toString().padStart(2, \"0\");\nconst outside = (start: Time, end: Time, min?: Time, max?: Time) =>\n  (min !== undefined && end.compare(min) < 0) || (max !== undefined && start.compare(max) > 0);\n\nfunction clamp(time: Time, min?: Time, max?: Time) {\n  if (min && time.compare(min) < 0) return min;\n  if (max && time.compare(max) > 0) return max;\n  return time;\n}\n\nfunction format(time: Time | null, hourCycle: 12 | 24, granularity: Granularity) {\n  const parts = (h: string, m: string, s: string) =>\n    [h, m, s].slice(0, granularity === \"hour\" ? 1 : granularity === \"minute\" ? 2 : 3).join(\":\");\n  if (!time) return parts(\"--\", \"--\", \"--\");\n  if (hourCycle === 24) return parts(pad(time.hour), pad(time.minute), pad(time.second));\n  return `${parts(pad(time.hour % 12 || 12), pad(time.minute), pad(time.second))} ${time.hour < 12 ? \"AM\" : \"PM\"}`;\n}\n\n/**\n * A button showing the time that opens columns of hours, minutes, seconds and\n * AM/PM. Each column is a react-aria `ListBox`, so arrow keys, Home/End and\n * typing a number move within it and Tab moves between columns.\n */\nfunction SimpleTimePicker({\n  value: controlled,\n  defaultValue = null,\n  onChange,\n  minValue,\n  maxValue,\n  hourCycle = 24,\n  granularity = \"minute\",\n  isDisabled,\n  \"aria-label\": ariaLabel = \"Time\",\n  className,\n}: SimpleTimePickerProps) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);\n  const value = controlled === undefined ? uncontrolled : controlled;\n  const time = value ?? clamp(new Time(0, 0), minValue, maxValue);\n  const isPm = time.hour >= 12;\n\n  const commit = (next: Time) => {\n    const clamped = clamp(next, minValue, maxValue);\n    setUncontrolled(clamped);\n    onChange?.(clamped);\n  };\n\n  const hours: Option[] = Array.from({ length: hourCycle }, (_, i) => {\n    const hour = hourCycle === 12 ? i + (isPm ? 12 : 0) : i;\n    return {\n      id: hour,\n      label: pad(hourCycle === 12 ? hour % 12 || 12 : hour),\n      isDisabled: outside(new Time(hour), new Time(hour, 59, 59), minValue, maxValue),\n    };\n  });\n  const minutes: Option[] = Array.from({ length: 60 }, (_, minute) => ({\n    id: minute,\n    label: pad(minute),\n    isDisabled: outside(\n      new Time(time.hour, minute),\n      new Time(time.hour, minute, 59),\n      minValue,\n      maxValue,\n    ),\n  }));\n  const seconds: Option[] = Array.from({ length: 60 }, (_, second) => {\n    const at = new Time(time.hour, time.minute, second);\n    return { id: second, label: pad(second), isDisabled: outside(at, at, minValue, maxValue) };\n  });\n  const periods: Option[] = [\n    {\n      id: 0,\n      label: \"AM\",\n      isDisabled: outside(new Time(0), new Time(11, 59, 59), minValue, maxValue),\n    },\n    {\n      id: 12,\n      label: \"PM\",\n      isDisabled: outside(new Time(12), new Time(23, 59, 59), minValue, maxValue),\n    },\n  ];\n\n  return (\n    <DialogTrigger>\n      <Button\n        data-slot=\"simple-time-picker\"\n        aria-label={`${ariaLabel}, ${format(value, hourCycle, granularity)}`}\n        isDisabled={isDisabled}\n        className={cn(\n          \"flex h-8 w-fit items-center gap-2 rounded-lg border border-input bg-transparent px-2.5 text-sm tabular-nums outline-none data-disabled:cursor-not-allowed data-disabled:opacity-50 data-focus-visible:border-ring data-focus-visible:ring-3 data-focus-visible:ring-ring/50 dark:bg-input/30\",\n          className,\n        )}\n      >\n        <ClockIcon aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n        <span className={cn(!value && \"text-muted-foreground\")}>\n          {format(value, hourCycle, granularity)}\n        </span>\n        <ChevronDownIcon aria-hidden=\"true\" className=\"size-4 opacity-50\" />\n      </Button>\n      <Popover\n        placement=\"bottom start\"\n        offset={4}\n        className=\"z-50 rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none data-entering:animate-in data-entering:fade-in-0 data-entering:zoom-in-95 data-exiting:animate-out data-exiting:fade-out-0 data-exiting:zoom-out-95\"\n      >\n        <Dialog aria-label={ariaLabel} className=\"flex h-56 max-h-[inherit] gap-1 p-1 outline-none\">\n          <TimeColumn\n            label=\"Hours\"\n            options={hours}\n            selected={time.hour}\n            onSelect={(hour) => commit(time.set({ hour }))}\n            autoFocus\n          />\n          {granularity !== \"hour\" && (\n            <TimeColumn\n              label=\"Minutes\"\n              options={minutes}\n              selected={time.minute}\n              onSelect={(minute) => commit(time.set({ minute }))}\n            />\n          )}\n          {granularity === \"second\" && (\n            <TimeColumn\n              label=\"Seconds\"\n              options={seconds}\n              selected={time.second}\n              onSelect={(second) => commit(time.set({ second }))}\n            />\n          )}\n          {hourCycle === 12 && (\n            <TimeColumn\n              label=\"AM/PM\"\n              options={periods}\n              selected={isPm ? 12 : 0}\n              onSelect={(period) => commit(time.set({ hour: (time.hour % 12) + period }))}\n            />\n          )}\n        </Dialog>\n      </Popover>\n    </DialogTrigger>\n  );\n}\n\nfunction TimeColumn({\n  label,\n  options,\n  selected,\n  onSelect,\n  autoFocus,\n}: {\n  label: string;\n  options: Option[];\n  selected: number;\n  onSelect: (value: number) => void;\n  autoFocus?: boolean;\n}) {\n  // Start with the selected option centered. Set scrollTop directly (not\n  // scrollIntoView) so only the column scrolls, and wait a frame for\n  // react-aria to render the collection.\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => {\n      const list = ref.current;\n      const item = list?.querySelector<HTMLElement>(\"[aria-selected=true]\");\n      if (list && item)\n        list.scrollTop = item.offsetTop - (list.clientHeight - item.offsetHeight) / 2;\n    });\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  return (\n    <ListBox\n      ref={ref}\n      aria-label={label}\n      selectionMode=\"single\"\n      disallowEmptySelection\n      selectedKeys={[selected]}\n      onSelectionChange={(keys) => {\n        const [key] = keys as Set<Key>;\n        if (key !== undefined) onSelect(Number(key));\n      }}\n      disabledKeys={options.filter((option) => option.isDisabled).map((option) => option.id)}\n      autoFocus={autoFocus}\n      items={options}\n      className=\"relative flex h-full w-14 flex-col gap-0.5 overflow-y-auto outline-none\"\n    >\n      {(option) => (\n        <ListBoxItem\n          textValue={option.label}\n          className=\"flex h-8 shrink-0 cursor-default items-center gap-1 rounded-md px-1.5 text-sm tabular-nums outline-none data-disabled:opacity-40 data-focus-visible:ring-2 data-focus-visible:ring-ring/50 data-focused:bg-accent data-hovered:bg-accent data-selected:font-medium\"\n        >\n          {({ isSelected }) => (\n            <>\n              <CheckIcon\n                aria-hidden=\"true\"\n                className={cn(\"size-3.5 shrink-0\", !isSelected && \"invisible\")}\n              />\n              {option.label}\n            </>\n          )}\n        </ListBoxItem>\n      )}\n    </ListBox>\n  );\n}\n\nexport type { SimpleTimePickerProps };\nexport { SimpleTimePicker };\n",
      "type": "registry:component",
      "target": "components/fma-ui/simple-time-picker.tsx"
    }
  ],
  "meta": {
    "status": "stable",
    "source": "third-party",
    "origin": "huybuidac/shadcn-datetime-picker (https://github.com/huybuidac/shadcn-datetime-picker, MIT), via EMITTE; rebuilt on react-aria ListBox",
    "tags": [
      "forms",
      "time",
      "picker",
      "react-aria"
    ]
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}