{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wheel-picker",
  "type": "registry:ui",
  "title": "Wheel Picker",
  "description": "An iOS-style drum picker: native scroll-snap momentum, a 3D cylinder per column, keyboard and typeahead.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/wheel-picker.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport interface WheelPickerOption {\n  value: string;\n  label: string;\n  disabled?: boolean;\n}\n\nexport interface WheelPickerColumn {\n  /** Key of this column in the value record. */\n  key: string;\n  /** Accessible name of the column's listbox, e.g. \"Hours\". */\n  label: string;\n  options: WheelPickerOption[];\n  /** CSS width (e.g. \"4.5rem\"). Columns without a width share the remaining space. */\n  width?: string;\n  /** Text alignment of the items. */\n  align?: \"start\" | \"center\" | \"end\";\n}\n\nexport type WheelPickerValue = Record<string, string>;\n\nexport interface WheelPickerProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\"> {\n  columns: WheelPickerColumn[];\n  /** Controlled value: one option value per column key. */\n  value?: WheelPickerValue;\n  /** Initial value when uncontrolled (missing columns start at their first enabled option). */\n  defaultValue?: WheelPickerValue;\n  /** Called when a column settles on a new option. */\n  onChange?: (value: WheelPickerValue, changedKey: string) => void;\n  /** Accessible name of the whole picker (role=\"group\"). */\n  label?: string;\n  /** Row height in px. */\n  itemHeight?: number;\n  /** Visible rows; even numbers are rounded up so one row sits in the middle. */\n  visibleCount?: number;\n  /** Degrees each row turns on the drum; 0 renders a flat list. */\n  curve?: number;\n  /** Colour of the selection band edges and focus ring (any CSS colour). */\n  accent?: string;\n}\n\nconst firstEnabled = (options: WheelPickerOption[]) => Math.max(0, options.findIndex((o) => !o.disabled));\n\n/** Nearest enabled index to `i`, preferring `dir` on ties. */\nfunction nearestEnabled(options: WheelPickerOption[], i: number, dir: 1 | -1 = 1) {\n  if (options[i] && !options[i].disabled) return i;\n  for (let n = 1; n < options.length; n++) {\n    for (const s of [dir, -dir]) {\n      const j = i + s * n;\n      if (options[j] && !options[j].disabled) return j;\n    }\n  }\n  return i;\n}\n\ninterface ColumnProps {\n  column: WheelPickerColumn;\n  value: string | undefined;\n  onSelect: (value: string) => void;\n  itemHeight: number;\n  visibleCount: number;\n  curve: number;\n  reduceMotion: boolean;\n  baseId: string;\n}\n\nfunction WheelColumn({ column, value, onSelect, itemHeight, visibleCount, curve, reduceMotion, baseId }: ColumnProps) {\n  const { options } = column;\n  const found = options.findIndex((o) => o.value === value);\n  const index = found === -1 ? firstEnabled(options) : found;\n  const optionId = (i: number) => `${baseId}-${i}`;\n\n  const scrollerRef = React.useRef<HTMLDivElement>(null);\n  const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const committed = React.useRef(index);\n  const frame = React.useRef(0);\n  const settleTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const drag = React.useRef<{ y: number; top: number; moved: boolean } | null>(null);\n  const suppressClick = React.useRef(false);\n  const typeahead = React.useRef({ text: \"\", at: 0 });\n  const mounted = React.useRef(false);\n  /** Row a keyboard/click/value-driven scroll is heading to; null while the user scrolls. */\n  const target = React.useRef<number | null>(null);\n\n  const pad = (itemHeight * (visibleCount - 1)) / 2;\n\n  /** Places every row on the drum according to the current scroll position (no React state). */\n  const paint = React.useCallback(() => {\n    frame.current = 0;\n    const el = scrollerRef.current;\n    if (!el) return;\n    const center = el.scrollTop / itemHeight;\n    const step = (curve * Math.PI) / 180;\n    // Radius of a drum whose rows are `itemHeight` tall chords of `step` radians.\n    const radius = step > 0 ? itemHeight / (2 * Math.tan(step / 2)) : 0;\n    const reach = visibleCount / 2 + 1;\n    itemRefs.current.forEach((item, i) => {\n      if (!item) return;\n      const d = i - center;\n      if (Math.abs(d) > reach) {\n        item.style.visibility = \"hidden\";\n        return;\n      }\n      item.style.visibility = \"\";\n      if (step > 0) {\n        const theta = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, d * step));\n        const y = radius * Math.sin(theta) - d * itemHeight;\n        const z = radius * (Math.cos(theta) - 1);\n        item.style.transform = `translate3d(0, ${y.toFixed(2)}px, ${z.toFixed(2)}px) rotateX(${(-theta).toFixed(4)}rad)`;\n      } else {\n        item.style.transform = \"\";\n      }\n      item.style.opacity = Math.max(0.18, 1 - Math.abs(d) * (0.9 / reach)).toFixed(3);\n      item.dataset.center = Math.abs(d) < 0.5 ? \"true\" : \"false\";\n    });\n  }, [itemHeight, curve, visibleCount]);\n\n  const scrollToIndex = (i: number, smooth: boolean) => {\n    target.current = i;\n    scrollerRef.current?.scrollTo({ top: i * itemHeight, behavior: smooth && !reduceMotion ? \"smooth\" : \"instant\" });\n  };\n\n  const commit = (i: number) => {\n    const option = options[i];\n    if (!option || option.disabled) return;\n    committed.current = i;\n    if (option.value !== value) onSelect(option.value);\n  };\n\n  /** Runs once scrolling has stopped: lands on an enabled row and reports it. */\n  const settle = () => {\n    const el = scrollerRef.current;\n    if (!el || drag.current) return;\n    const raw = Math.min(options.length - 1, Math.max(0, Math.round(el.scrollTop / itemHeight)));\n    const aim = target.current;\n    target.current = null;\n    if (aim !== null) {\n      // A programmatic smooth scroll can be cut short by snapping; finish it instantly.\n      if (Math.abs(el.scrollTop - aim * itemHeight) > 1) el.scrollTo({ top: aim * itemHeight, behavior: \"instant\" });\n      if (aim !== committed.current) commit(aim);\n      return;\n    }\n    const i = nearestEnabled(options, raw, el.scrollTop / itemHeight > raw ? 1 : -1);\n    if (Math.abs(el.scrollTop - i * itemHeight) > 1) scrollToIndex(i, true);\n    if (i !== committed.current) commit(i);\n  };\n\n  // Keep the scroll position in step with the value (initial mount, keyboard, controlled updates).\n  React.useLayoutEffect(() => {\n    const el = scrollerRef.current;\n    if (!el) return;\n    committed.current = index;\n    const top = index * itemHeight;\n    // Skip when a scroll toward this row is already running (keyboard and clicks start their own).\n    if (!drag.current && target.current !== index && Math.abs(el.scrollTop - top) > 1) {\n      target.current = index;\n      el.scrollTo({ top, behavior: mounted.current && !reduceMotion ? \"smooth\" : \"instant\" });\n    }\n    mounted.current = true;\n    paint();\n  }, [index, itemHeight, visibleCount, reduceMotion, paint]);\n\n  React.useEffect(\n    () => () => {\n      cancelAnimationFrame(frame.current);\n      clearTimeout(settleTimer.current);\n    },\n    [],\n  );\n\n  const handleScroll = () => {\n    if (!frame.current) frame.current = requestAnimationFrame(paint);\n    clearTimeout(settleTimer.current);\n    settleTimer.current = setTimeout(settle, 140);\n  };\n\n  const move = (i: number, dir: 1 | -1) => {\n    const next = nearestEnabled(options, Math.min(options.length - 1, Math.max(0, i)), dir);\n    commit(next);\n    // Uncontrolled parents re-render with the new index; scroll here too so it is immediate.\n    scrollToIndex(next, true);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const page = Math.max(1, visibleCount - 1);\n    const keys: Record<string, () => void> = {\n      ArrowDown: () => move(index + 1, 1),\n      ArrowUp: () => move(index - 1, -1),\n      PageDown: () => move(index + page, 1),\n      PageUp: () => move(index - page, -1),\n      Home: () => move(0, 1),\n      End: () => move(options.length - 1, -1),\n    };\n    const action = keys[event.key];\n    if (action) {\n      event.preventDefault();\n      action();\n      return;\n    }\n    // Typeahead: printable characters jump to the next option whose label starts with them.\n    if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {\n      const now = event.timeStamp;\n      const t = typeahead.current;\n      t.text = now - t.at > 600 ? event.key.toLowerCase() : t.text + event.key.toLowerCase();\n      t.at = now;\n      const repeated = t.text.split(\"\").every((c) => c === t.text[0]);\n      const needle = repeated ? t.text[0] : t.text;\n      const start = repeated || t.text.length === 1 ? index + 1 : index;\n      for (let n = 0; n < options.length; n++) {\n        const i = (start + n) % options.length;\n        if (!options[i].disabled && options[i].label.toLowerCase().startsWith(needle)) {\n          event.preventDefault();\n          commit(i);\n          scrollToIndex(i, true);\n          break;\n        }\n      }\n    }\n  };\n\n  // Mouse drag: native scrolling covers wheel and touch; this adds grab-to-spin for mice.\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== \"mouse\" || event.button !== 0) return;\n    const el = event.currentTarget;\n    drag.current = { y: event.clientY, top: el.scrollTop, moved: false };\n    target.current = null;\n    suppressClick.current = false;\n    const onMove = (e: PointerEvent) => {\n      const d = drag.current;\n      if (!d) return;\n      const dy = e.clientY - d.y;\n      if (!d.moved && Math.abs(dy) > 3) {\n        d.moved = true;\n        el.style.scrollSnapType = \"none\"; // snapping would fight the drag\n      }\n      if (d.moved) el.scrollTop = d.top - dy;\n    };\n    const onUp = () => {\n      const moved = drag.current?.moved ?? false;\n      drag.current = null;\n      window.removeEventListener(\"pointermove\", onMove);\n      window.removeEventListener(\"pointerup\", onUp);\n      window.removeEventListener(\"pointercancel\", onUp);\n      if (!moved) return;\n      suppressClick.current = true;\n      el.style.scrollSnapType = \"\";\n      const i = Math.min(options.length - 1, Math.max(0, Math.round(el.scrollTop / itemHeight)));\n      scrollToIndex(nearestEnabled(options, i), true);\n      handleScroll();\n    };\n    window.addEventListener(\"pointermove\", onMove);\n    window.addEventListener(\"pointerup\", onUp);\n    window.addEventListener(\"pointercancel\", onUp);\n  };\n\n  const align = column.align === \"start\" ? \"justify-start pl-4\" : column.align === \"end\" ? \"justify-end pr-4\" : \"justify-center\";\n\n  return (\n    <div\n      ref={scrollerRef}\n      role=\"listbox\"\n      tabIndex={0}\n      aria-label={column.label}\n      aria-activedescendant={optionId(index)}\n      onScroll={handleScroll}\n      // Direct user scrolling takes over from any programmatic scroll.\n      onWheel={() => (target.current = null)}\n      onTouchStart={() => (target.current = null)}\n      onKeyDown={handleKeyDown}\n      onPointerDown={handlePointerDown}\n      className=\"relative min-w-0 cursor-grab snap-y snap-mandatory overflow-y-scroll overscroll-contain rounded-lg outline-hidden [scrollbar-width:none] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--wp-accent)] active:cursor-grabbing [&::-webkit-scrollbar]:hidden\"\n      style={{\n        height: itemHeight * visibleCount,\n        paddingBlock: pad,\n        flex: column.width ? `0 0 ${column.width}` : \"1 1 0\",\n        perspective: 1000,\n      }}\n    >\n      {options.map((option, i) => (\n        <div\n          key={option.value}\n          ref={(el) => {\n            itemRefs.current[i] = el;\n          }}\n          id={optionId(i)}\n          role=\"option\"\n          aria-selected={i === index}\n          aria-disabled={option.disabled || undefined}\n          onClick={() => {\n            if (suppressClick.current) {\n              suppressClick.current = false;\n              return;\n            }\n            if (option.disabled) return;\n            commit(i);\n            scrollToIndex(i, true);\n          }}\n          style={{ height: itemHeight }}\n          className={`flex snap-center select-none items-center ${align} whitespace-nowrap text-[17px] tabular-nums tracking-tight text-zinc-500 transition-[color] duration-100 [backface-visibility:hidden] data-[center=true]:font-medium data-[center=true]:text-zinc-950 dark:text-zinc-500 dark:data-[center=true]:text-white motion-reduce:transition-none ${\n            option.disabled ? \"line-through decoration-zinc-400/60\" : \"\"\n          }`}\n        >\n          {option.label}\n        </div>\n      ))}\n    </div>\n  );\n}\n\n/**\n * An iOS-style drum picker. Each column is a native scroll-snap list, so wheel, trackpad and\n * touch momentum come from the browser; on every scroll frame the rows are placed on a cylinder\n * (translate3d + rotateX by distance from the centre) without React re-renders. Each column is\n * a focusable role=\"listbox\" with aria-activedescendant, arrow/page/Home/End keys and typeahead.\n */\nexport function WheelPicker({\n  columns,\n  value,\n  defaultValue,\n  onChange,\n  label = \"Picker\",\n  itemHeight = 36,\n  visibleCount = 5,\n  curve = 18,\n  accent = \"#FF7448\",\n  className = \"\",\n  style,\n  ...props\n}: WheelPickerProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const [inner, setInner] = React.useState<WheelPickerValue>(() => defaultValue ?? {});\n  const current = value ?? inner;\n  const rows = visibleCount % 2 === 0 ? visibleCount + 1 : Math.max(1, visibleCount);\n\n  const select = (key: string, next: string) => {\n    const merged = { ...current, [key]: next };\n    if (value === undefined) setInner(merged);\n    onChange?.(merged, key);\n  };\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      className={`relative w-full select-none rounded-2xl border border-zinc-900/10 bg-white p-1.5 dark:border-white/10 dark:bg-zinc-950 ${className}`}\n      style={{ [\"--wp-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      {/* Selection band, drawn behind the rows. */}\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-1.5 top-1/2 -translate-y-1/2 rounded-lg border-y border-[color-mix(in_srgb,var(--wp-accent)_55%,transparent)] bg-zinc-900/[0.04] dark:bg-white/[0.06]\"\n        style={{ height: itemHeight }}\n      />\n      <div className=\"relative flex gap-1 [mask-image:linear-gradient(to_bottom,transparent,black_28%,black_72%,transparent)]\">\n        {columns.map((column, ci) => (\n          <WheelColumn\n            key={column.key}\n            column={column}\n            value={current[column.key]}\n            onSelect={(v) => select(column.key, v)}\n            itemHeight={itemHeight}\n            visibleCount={rows}\n            curve={curve}\n            reduceMotion={reduceMotion}\n            baseId={`wp-${uid}-${ci}`}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/wheel-picker"
}
