{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "time-wheel",
  "type": "registry:component",
  "title": "Time Wheel",
  "description": "A drum-style time picker: flick the hours and minutes and they spin with momentum, curl away on a 3D wheel and snap onto the row, by touch, trackpad, mouse or keys.",
  "categories": [
    "component",
    "Forms"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/time-wheel.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst ITEM = 36;\nconst VISIBLE = 5;\nconst DRUM_RADIUS = ITEM * 2.4;\nconst LOOP_COPIES = 7;\n\ninterface WheelProps {\n  items: string[];\n  index: number;\n  onIndexChange: (index: number) => void;\n  loop?: boolean;\n  label: string;\n  valueText?: string;\n  className?: string;\n}\n\nfunction Wheel({ items, index, onIndexChange, loop = false, label, valueText, className }: WheelProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const rows = useRef<(HTMLDivElement | null)[]>([]);\n  const settle = useRef<number | undefined>(undefined);\n  const drag = useRef<{ y: number; top: number; t: number; v: number } | null>(null);\n  const reduce = useReducedMotion();\n  const copies = loop ? LOOP_COPIES : 1;\n  const total = items.length * copies;\n  const middle = loop ? Math.floor(LOOP_COPIES / 2) * items.length : 0;\n  const latest = useRef({ index, onIndexChange });\n  latest.current = { index, onIndexChange };\n\n  const paintDrum = useCallback(() => {\n    const el = ref.current;\n    if (!el) return;\n    const centre = el.scrollTop + (ITEM * VISIBLE) / 2;\n    for (let i = 0; i < rows.current.length; i++) {\n      const row = rows.current[i];\n      if (!row) continue;\n      const offset = i * ITEM + ITEM / 2 + ITEM * 2 - centre;\n      if (Math.abs(offset) > ITEM * 4) {\n        if (row.style.visibility !== \"hidden\") row.style.visibility = \"hidden\";\n        continue;\n      }\n      const theta = Math.max(-1.45, Math.min(1.45, offset / DRUM_RADIUS));\n      row.style.visibility = \"visible\";\n      row.style.transform = `perspective(480px) translateY(${DRUM_RADIUS * Math.sin(theta) - offset}px) rotateX(${-theta}rad)`;\n      row.style.opacity = String(Math.max(0, Math.cos(theta) ** 2.2));\n      row.dataset.centre = Math.abs(offset) < ITEM / 2 ? \"true\" : \"false\";\n    }\n  }, []);\n\n  const scrollToIndex = useCallback(\n    (i: number, smooth: boolean) => {\n      ref.current?.scrollTo({ top: i * ITEM, behavior: smooth && !reduce ? \"smooth\" : \"instant\" });\n    },\n    [reduce],\n  );\n\n  const nearest = () => Math.round((ref.current?.scrollTop ?? 0) / ITEM);\n\n  const onSettled = useCallback(() => {\n    if (drag.current) return;\n    const at = nearest();\n    const value = ((at % items.length) + items.length) % items.length;\n    if (loop) {\n      const copy = Math.floor(at / items.length);\n      if (copy !== Math.floor(LOOP_COPIES / 2)) {\n        const el = ref.current;\n        if (el) el.scrollTop = (middle + value) * ITEM + (el.scrollTop - at * ITEM);\n      }\n    }\n    if (value !== latest.current.index) latest.current.onIndexChange(value);\n  }, [items.length, loop, middle]);\n\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    el.scrollTop = (middle + index) * ITEM;\n    paintDrum();\n    // Mount only: later index changes are handled below.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el || drag.current) return;\n    const at = nearest();\n    const current = ((at % items.length) + items.length) % items.length;\n    if (current === index) return;\n    let delta = index - current;\n    if (loop && Math.abs(delta) > items.length / 2) delta -= Math.sign(delta) * items.length;\n    scrollToIndex(at + delta, true);\n  }, [index, items.length, loop, scrollToIndex]);\n\n  useEffect(() => () => window.clearTimeout(settle.current), []);\n\n  const onScroll = () => {\n    paintDrum();\n    window.clearTimeout(settle.current);\n    settle.current = window.setTimeout(onSettled, 110);\n  };\n\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== \"mouse\" || event.button !== 0) return;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drag.current = { y: event.clientY, top: event.currentTarget.scrollTop, t: performance.now(), v: 0 };\n    event.currentTarget.style.scrollSnapType = \"none\";\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d) return;\n    const now = performance.now();\n    const top = d.top - (event.clientY - d.y);\n    const dt = Math.max(now - d.t, 1);\n    d.v = 0.8 * ((event.currentTarget.scrollTop - top) / dt) + 0.2 * d.v;\n    d.t = now;\n    event.currentTarget.scrollTop = top;\n  };\n  const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d) return;\n    drag.current = null;\n    const el = event.currentTarget;\n    const moved = Math.abs(el.scrollTop - d.top) > 3;\n    const fling = reduce ? 0 : -d.v * 140;\n    const target = moved ? Math.round((el.scrollTop + fling) / ITEM) : Math.round((el.scrollTop + (event.clientY - el.getBoundingClientRect().top) - (ITEM * VISIBLE) / 2) / ITEM);\n    el.style.scrollSnapType = \"\";\n    scrollToIndex(Math.max(0, Math.min(total - 1, target)), true);\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const steps: Record<string, number> = { ArrowUp: -1, ArrowDown: 1, PageUp: -5, PageDown: 5 };\n    if (event.key in steps) {\n      event.preventDefault();\n      const next = index + steps[event.key];\n      onIndexChange(loop ? (next + items.length) % items.length : Math.max(0, Math.min(items.length - 1, next)));\n    } else if (event.key === \"Home\" || event.key === \"End\") {\n      event.preventDefault();\n      onIndexChange(event.key === \"Home\" ? 0 : items.length - 1);\n    }\n  };\n\n  return (\n    <div className={cn(\"relative\", className)}>\n      <div\n        aria-label={label}\n        aria-valuemax={items.length - 1}\n        aria-valuemin={0}\n        aria-valuenow={index}\n        aria-valuetext={valueText ?? items[index]}\n        className={cn(\n          \"peer relative h-[180px] cursor-grab touch-pan-y snap-y snap-mandatory overflow-y-scroll overscroll-contain outline-none [scrollbar-width:none] active:cursor-grabbing [&::-webkit-scrollbar]:hidden\",\n          \"[mask-image:linear-gradient(to_bottom,transparent,black_28%,black_72%,transparent)]\",\n        )}\n        onKeyDown={onKeyDown}\n        onPointerCancel={onPointerUp}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onScroll={onScroll}\n        ref={ref}\n        role=\"spinbutton\"\n        tabIndex={0}\n      >\n        <div style={{ paddingTop: ITEM * 2, paddingBottom: ITEM * 2 }}>\n          {Array.from({ length: total }, (_, i) => (\n            <div\n              aria-hidden\n              className=\"flex snap-center select-none items-center justify-center text-lg text-muted-foreground tabular-nums transition-colors duration-100 will-change-transform data-[centre=true]:font-medium data-[centre=true]:text-foreground\"\n              key={i}\n              ref={(node) => {\n                rows.current[i] = node;\n              }}\n              style={{ height: ITEM }}\n            >\n              {items[i % items.length]}\n            </div>\n          ))}\n        </div>\n      </div>\n      <span\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-0 top-1/2 h-9 -translate-y-1/2 rounded-lg opacity-0 ring-2 ring-ring peer-focus-visible:opacity-100\"\n      />\n    </div>\n  );\n}\n\nexport interface Time {\n  hours: number;\n  minutes: number;\n}\n\nexport interface TimeWheelProps {\n  value: Time;\n  onValueChange: (value: Time) => void;\n  minuteStep?: number;\n  hour12?: boolean;\n  className?: string;\n}\n\nexport function TimeWheel({ value, onValueChange, minuteStep = 1, hour12 = true, className }: TimeWheelProps) {\n  const hours = hour12 ? Array.from({ length: 12 }, (_, i) => String(i === 0 ? 12 : i)) : Array.from({ length: 24 }, (_, i) => String(i).padStart(2, \"0\"));\n  const minutes = Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => String(i * minuteStep).padStart(2, \"0\"));\n  const pm = value.hours >= 12;\n  const hourIndex = hour12 ? value.hours % 12 : value.hours;\n  const minuteIndex = Math.round(value.minutes / minuteStep) % minutes.length;\n  const spoken = `${hours[hourIndex]}${hour12 ? (pm ? \" PM\" : \" AM\") : \"\"}`;\n\n  return (\n    <div className={cn(\"relative grid grid-cols-[1fr_auto_1fr_auto] items-center gap-1 px-2\", !hour12 && \"grid-cols-[1fr_auto_1fr]\", className)}>\n      <div aria-hidden className=\"pointer-events-none absolute inset-x-0 top-1/2 h-9 -translate-y-1/2 rounded-xl bg-muted ring-1 ring-border ring-inset\" />\n      <Wheel\n        index={hourIndex}\n        items={hours}\n        label=\"Hours\"\n        loop\n        onIndexChange={(i) => onValueChange({ ...value, hours: hour12 ? i + (pm ? 12 : 0) : i })}\n        valueText={spoken}\n      />\n      <span aria-hidden className=\"relative -mt-0.5 font-medium text-foreground text-lg\">\n        :\n      </span>\n      <Wheel\n        index={minuteIndex}\n        items={minutes}\n        label=\"Minutes\"\n        loop\n        onIndexChange={(i) => onValueChange({ ...value, minutes: i * minuteStep })}\n      />\n      {hour12 && (\n        <Wheel\n          className=\"w-16\"\n          index={pm ? 1 : 0}\n          items={[\"AM\", \"PM\"]}\n          label=\"AM or PM\"\n          onIndexChange={(i) => onValueChange({ ...value, hours: (value.hours % 12) + (i === 1 ? 12 : 0) })}\n        />\n      )}\n    </div>\n  );\n}\n"
    }
  ]
}