{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "time-picker",
  "type": "registry:ui",
  "title": "Time picker",
  "description": "Choose a time with sensible keyboard behavior.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/time-picker/time-picker.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { Variants } from \"motion/react\";\nimport { ChevronDown, Clock3 } from \"lucide-react\";\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface TimePickerProps {\n  label: string;\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string) => void;\n  description?: string;\n  placeholder?: string;\n  minuteStep?: 1 | 5 | 10 | 15 | 30;\n  format?: \"12h\" | \"24h\";\n  disabled?: boolean;\n  className?: string;\n}\n\n/* The trigger anchors the menu, so press feedback stays in colour; it never scales. */\nconst triggerClass =\n  \"relative flex min-h-control-md w-full items-center gap-[9px] rounded-control border border-line bg-surface px-3 text-left text-(length:--text-sm) text-ink [transition:border-color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard),box-shadow_var(--duration-fast)_var(--ease-standard)] enabled:hover:border-line-strong enabled:hover:bg-hover enabled:active:border-line-strong enabled:active:bg-hover aria-expanded:border-line-strong aria-expanded:bg-hover disabled:cursor-not-allowed disabled:opacity-50 [&>svg:first-child]:flex-[0_0_auto] [&>svg:first-child]:text-ink-3 motion-reduce:transition-none\";\nconst menuClass =\n  \"absolute top-[calc(100%+6px)] right-0 left-0 z-80 max-h-[250px] origin-top overflow-y-auto overscroll-contain rounded-panel border border-line bg-surface p-[5px] shadow-floating will-change-[transform,opacity]\";\n/* Arrow keys move the highlight often, so it changes almost instantly. The dot marks the chosen time, so only one row is ever highlighted. */\nconst optionClass =\n  \"flex min-h-9 w-full items-center justify-between rounded-[calc(var(--radius-panel)-5px)] bg-transparent px-3 text-left text-(length:--text-sm) text-ink tabular-nums transition-[background-color] duration-[80ms] ease-standard data-active:bg-hover motion-reduce:transition-none\";\n\nconst pad = (value: number) => String(value).padStart(2, \"0\");\nconst toMinutes = (value: string) => {\n  const [h = NaN, m = NaN] = value.split(\":\").map(Number);\n  return Number.isFinite(h) && Number.isFinite(m) ? h * 60 + m : 0;\n};\nconst enter = motionTokens.ease.enter;\nconst standard = motionTokens.ease.standard;\n/** The shown time rolls like a clock face: a later time rises from below, an earlier one drops from above. */\nconst valueRoll: Variants = {\n  enter: (direction: number) => ({\n    opacity: 0,\n    y: `${direction * 0.35}em`,\n    filter: `blur(${motionTokens.blur.soft}px)`,\n  }),\n  center: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: { duration: motionTokens.duration.standard, ease: enter },\n  },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${direction * -0.3}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.fast, ease: standard },\n  }),\n};\n/** Reduced motion keeps a short crossfade; the resting state matches valueRoll so server and client markup agree. */\nconst valueFade: Variants = {\n  enter: { opacity: 0 },\n  center: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } },\n  exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } },\n};\n\nexport function TimePicker({\n  label,\n  value,\n  defaultValue = \"09:00\",\n  onChange,\n  description,\n  placeholder = \"Select a time\",\n  minuteStep = 15,\n  format = \"12h\",\n  disabled = false,\n  className,\n}: TimePickerProps) {\n  const id = useId();\n  const labelId = `${id}-label`;\n  const valueId = `${id}-value`;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);\n  const centerOnOpen = useRef(false);\n  const [internal, setInternal] = useState(defaultValue);\n  const [open, setOpen] = useState(false);\n  const [activeIndex, setActiveIndex] = useState(-1);\n  const selected = value ?? internal;\n  const reduce = useReducedMotion();\n  const [previous, setPrevious] = useState(selected);\n  const [direction, setDirection] = useState(1);\n  if (previous !== selected) {\n    setPrevious(selected);\n    setDirection(toMinutes(selected) >= toMinutes(previous) ? 1 : -1);\n  }\n  const options = Array.from({ length: Math.ceil(1440 / minuteStep) }, (_, index) => {\n    const minutes = index * minuteStep;\n    const hour = Math.floor(minutes / 60);\n    const minute = minutes % 60;\n    return `${pad(hour)}:${pad(minute)}`;\n  });\n  const display = (raw: string) => {\n    const minutes = toMinutes(raw);\n    const hour = Math.floor(minutes / 60);\n    const minute = minutes % 60;\n    return format === \"24h\"\n      ? `${pad(hour)}:${pad(minute)}`\n      : `${hour % 12 || 12}:${pad(minute)} ${hour < 12 ? \"AM\" : \"PM\"}`;\n  };\n  useEffect(() => {\n    const close = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", close);\n    return () => document.removeEventListener(\"pointerdown\", close);\n  }, []);\n  // Runs before paint so the menu's first frame is already centered on the selected time.\n  useLayoutEffect(() => {\n    if (!open || activeIndex < 0) return;\n    // Scroll only the menu, never the page: the selected time opens centered, arrow keys then keep the highlight in view.\n    const option = optionRefs.current[activeIndex];\n    const menu = option?.parentElement;\n    if (!option || !menu) return;\n    const top = option.offsetTop;\n    const bottom = top + option.offsetHeight;\n    if (centerOnOpen.current) {\n      centerOnOpen.current = false;\n      menu.scrollTop = top - (menu.clientHeight - option.offsetHeight) / 2;\n    } else if (top < menu.scrollTop) menu.scrollTop = top;\n    else if (bottom > menu.scrollTop + menu.clientHeight) menu.scrollTop = bottom - menu.clientHeight;\n  }, [activeIndex, open]);\n  const choose = (next: string) => {\n    if (value === undefined) setInternal(next);\n    onChange?.(next);\n    setOpen(false);\n  };\n  const openMenu = () => {\n    const selectedIndex = options.indexOf(selected);\n    setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);\n    centerOnOpen.current = true;\n    setOpen(true);\n  };\n  const onKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      setOpen(false);\n      return;\n    }\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      if (open && activeIndex >= 0) choose(options[activeIndex]!);\n      else openMenu();\n      return;\n    }\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      if (!open) {\n        openMenu();\n        return;\n      }\n      setActiveIndex((index) => (index + (event.key === \"ArrowDown\" ? 1 : -1) + options.length) % options.length);\n    }\n  };\n  return (\n    <div ref={rootRef} className={cn(\"grid min-w-0 gap-2\", className)}>\n      <span id={labelId} className=\"text-sm leading-body font-medium\">\n        {label}\n      </span>\n      <div className=\"relative min-w-0\">\n        <button\n          type=\"button\"\n          className={triggerClass}\n          disabled={disabled}\n          aria-labelledby={`${labelId} ${valueId}`}\n          aria-haspopup=\"listbox\"\n          aria-expanded={open}\n          aria-controls={`${id}-listbox`}\n          onClick={() => (open ? setOpen(false) : openMenu())}\n          onKeyDown={onKeyDown}\n        >\n          <Clock3 size={16} aria-hidden=\"true\" />\n          <span id={valueId} className=\"sr-only\">\n            {selected ? display(selected) : placeholder}\n          </span>\n          <span\n            className=\"grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)] *:min-w-0 *:truncate *:[grid-area:1/1]\"\n            aria-hidden=\"true\"\n          >\n            <AnimatePresence initial={false} custom={direction}>\n              <motion.span\n                key={selected || \"placeholder\"}\n                className={selected ? \"tabular-nums\" : \"text-ink-3\"}\n                custom={direction}\n                variants={reduce ? valueFade : valueRoll}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n              >\n                {selected ? display(selected) : placeholder}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n          <ChevronDown\n            className={cn(\n              \"flex-[0_0_auto] text-ink-3 transition-transform duration-spring ease-spring motion-reduce:transition-none\",\n              open && \"rotate-180\",\n            )}\n            size={16}\n            aria-hidden=\"true\"\n          />\n        </button>\n        <AnimatePresence initial={false}>\n          {open && (\n            <motion.div\n              id={`${id}-listbox`}\n              className={menuClass}\n              role=\"listbox\"\n              aria-label={`${label} options`}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}\n              animate={{\n                opacity: 1,\n                y: 0,\n                scale: 1,\n                transition: reduce\n                  ? { duration: motionTokens.duration.instant }\n                  : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast, ease: enter } },\n              }}\n              exit={{\n                opacity: 0,\n                ...(reduce ? {} : { y: -4, scale: 0.98 }),\n                transition: { duration: motionTokens.duration.instant, ease: standard },\n              }}\n            >\n              {options.map((option, index) => (\n                <button\n                  ref={(node) => {\n                    optionRefs.current[index] = node;\n                  }}\n                  id={`${id}-option-${index}`}\n                  type=\"button\"\n                  role=\"option\"\n                  aria-selected={option === selected}\n                  data-active={activeIndex === index || undefined}\n                  className={optionClass}\n                  key={option}\n                  onPointerMove={() => {\n                    if (activeIndex !== index) setActiveIndex(index);\n                  }}\n                  onClick={() => choose(option)}\n                >\n                  {display(option)}\n                  {option === selected && <span className=\"size-1.5 rounded-full bg-ink\" aria-hidden=\"true\" />}\n                </button>\n              ))}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n      {description && <span className=\"text-(length:--text-xs) text-ink-3\">{description}</span>}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/time-picker/time-picker.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/time-picker",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "TimePicker",
    "tags": [
      "time",
      "field"
    ],
    "docs": "https://irisui.dev/components/time-picker"
  }
}
