{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "option-wheel",
  "title": "Option Wheel",
  "description": "A circular selection wheel with smooth physics-based scrolling and dynamic effects.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "amitgajare2/ariseui/utils"
  ],
  "files": [
    {
      "path": "components/ui/option-wheel.tsx",
      "content": "\"use client\";\n\nimport { useRef, useState, useCallback, useEffect, ComponentProps } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type OptionWheelProps = Omit<ComponentProps<\"div\">, \"onChange\"> & {\n  items?: string[];\n  defaultSelected?: number;\n  onChange?: (index: number, item: string) => void;\n  textColor?: string;\n  activeColor?: string;\n  side?: \"left\" | \"right\";\n  fontSize?: number;\n  spacing?: number;\n  curve?: number;\n  tilt?: number;\n  blur?: number;\n  fade?: number;\n  minOpacity?: number;\n  smoothing?: number;\n  inset?: number;\n  loop?: boolean;\n  draggable?: boolean;\n  soundUrl?: string;\n  soundVolume?: number;\n};\n\nconst DEFAULT_ITEMS = [\n  \"Ambient\",\n  \"House\",\n  \"Techno\",\n  \"Jazz\",\n  \"Lo-Fi\",\n  \"Synthwave\",\n  \"Trance\",\n  \"Funk\",\n  \"Disco\",\n  \"Hip-Hop\",\n  \"Chillwave\",\n  \"Drum & Bass\",\n];\n\nexport default function OptionWheel({\n  items = DEFAULT_ITEMS,\n  defaultSelected = 3,\n  onChange,\n  textColor = \"#a6a6a6\",\n  activeColor = \"#ffffff\",\n  side = \"left\",\n  fontSize = 3,\n  spacing = 1.4,\n  curve = 1,\n  tilt = 6,\n  blur = 2,\n  fade = 0.25,\n  minOpacity = 0.05,\n  smoothing = 200,\n  inset = 80,\n  loop = false,\n  draggable = true,\n  soundUrl = \"\",\n  soundVolume = 0.5,\n  className,\n  style,\n  ...props\n}: OptionWheelProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const itemRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const posRef = useRef(defaultSelected);\n  const targetRef = useRef(defaultSelected);\n  const rafRef = useRef<number | null>(null);\n  const lastRef = useRef(0);\n  const cfgRef = useRef<any>({});\n  const onChangeRef = useRef(onChange);\n  const selectedRef = useRef(defaultSelected);\n  const wheelTimerRef = useRef<NodeJS.Timeout | null>(null);\n  const dragRef = useRef<{ y: number; start: number; id: number } | null>(null);\n  const dragMovedRef = useRef(false);\n  const audioRef = useRef<HTMLAudioElement | null>(null);\n  const audioUrlRef = useRef(\"\");\n  const lastTickRef = useRef(0);\n  const [selectedIndex, setSelectedIndex] = useState(defaultSelected);\n  const [isDragging, setIsDragging] = useState(false);\n\n  const remPx =\n    typeof window !== \"undefined\"\n      ? parseFloat(getComputedStyle(document.documentElement).fontSize) || 16\n      : 16;\n\n  onChangeRef.current = onChange;\n  cfgRef.current = {\n    count: items.length,\n    items,\n    rowH: Math.max(fontSize * spacing * remPx, 1),\n    curve,\n    tilt,\n    blur,\n    fade,\n    minOpacity,\n    side,\n    loop,\n    smoothing,\n    draggable,\n    soundUrl,\n    soundVolume,\n  };\n\n  const runFrame = useCallback((now: number) => {\n    const dt = Math.min((now - lastRef.current) / 1000, 0.05);\n    lastRef.current = now;\n    const cfg = cfgRef.current;\n    const tau = Math.max(cfg.smoothing, 1) / 1000;\n    const k = 1 - Math.exp(-dt / tau);\n\n    const target = targetRef.current;\n    const cur = posRef.current;\n    let next = cur + (target - cur) * k;\n    const settled = Math.abs(target - next) < 0.001;\n    if (settled) next = target;\n    posRef.current = next;\n\n    const els = itemRefs.current;\n    const n = cfg.count;\n    const mirror = cfg.side === \"right\" ? -1 : 1;\n    const tiltRad = (cfg.tilt * Math.PI) / 180;\n    const R = tiltRad > 0.0005 ? cfg.rowH / tiltRad : 0;\n\n    for (let i = 0; i < n; i++) {\n      const el = els[i];\n      if (!el) continue;\n      let d = i - next;\n      if (cfg.loop && n > 1) {\n        d = ((d % n) + n) % n;\n        if (d > n / 2) d -= n;\n      }\n      const dist = Math.abs(d);\n      let x = 0;\n      let y = d * cfg.rowH;\n      let rot = 0;\n      if (R > 0) {\n        const ang = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, d * tiltRad));\n        y = R * Math.sin(ang);\n        x = -mirror * R * (1 - Math.cos(ang)) * cfg.curve;\n        rot = (mirror * ang * 180) / Math.PI;\n      }\n      el.style.transform = `translate(${x.toFixed(2)}px, calc(${y.toFixed(2)}px - 50%)) rotate(${rot.toFixed(3)}deg)`;\n      el.style.opacity = String(Math.max(cfg.minOpacity, 1 - dist * cfg.fade));\n      el.style.filter =\n        cfg.blur > 0 ? `blur(${(dist * cfg.blur).toFixed(2)}px)` : \"none\";\n      el.style.setProperty(\n        \"--ow-p\",\n        Math.max(0, 1 - Math.min(dist, 1)).toFixed(4)\n      );\n    }\n\n    rafRef.current = settled ? null : requestAnimationFrame(runFrame);\n  }, []);\n\n  const startLoop = useCallback(() => {\n    if (rafRef.current != null) {\n      cancelAnimationFrame(rafRef.current);\n    }\n    lastRef.current = performance.now();\n    rafRef.current = requestAnimationFrame(runFrame);\n  }, [runFrame]);\n\n  const playTick = useCallback(() => {\n    const { soundUrl, soundVolume } = cfgRef.current;\n    if (!soundUrl) return;\n    const now = performance.now();\n    if (now - lastTickRef.current < 70) return;\n    lastTickRef.current = now;\n    if (!audioRef.current || audioUrlRef.current !== soundUrl) {\n      audioRef.current = new Audio(soundUrl);\n      audioRef.current.preload = \"auto\";\n      audioUrlRef.current = soundUrl;\n    }\n    const audio = audioRef.current;\n    audio.volume = Math.min(Math.max(soundVolume, 0), 1);\n    audio.currentTime = 0;\n    audio.play()?.catch(() => {});\n  }, []);\n\n  const applyTarget = useCallback(\n    (value: number, snap: boolean) => {\n      const cfg = cfgRef.current;\n      let v = value;\n      if (!cfg.loop) v = Math.min(Math.max(v, 0), Math.max(cfg.count - 1, 0));\n      if (snap) v = Math.round(v);\n      targetRef.current = v;\n      const idx = ((Math.round(v) % cfg.count) + cfg.count) % cfg.count;\n      if (idx !== selectedRef.current) {\n        selectedRef.current = idx;\n        setSelectedIndex(idx);\n        onChangeRef.current?.(idx, cfg.items[idx]);\n        playTick();\n      }\n      startLoop();\n    },\n    [startLoop, playTick]\n  );\n\n  useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const onWheel = (e: WheelEvent) => {\n      e.preventDefault();\n      const cfg = cfgRef.current;\n      const delta = e.deltaMode === 1 ? e.deltaY * 24 : e.deltaY;\n      const step = Math.max(-1, Math.min(1, delta / cfg.rowH));\n      applyTarget(targetRef.current + step, false);\n      if (wheelTimerRef.current) clearTimeout(wheelTimerRef.current);\n      wheelTimerRef.current = setTimeout(\n        () => applyTarget(targetRef.current, true),\n        140\n      );\n    };\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => {\n      el.removeEventListener(\"wheel\", onWheel);\n      if (wheelTimerRef.current) clearTimeout(wheelTimerRef.current);\n    };\n  }, [applyTarget]);\n\n  const handlePointerDown = useCallback((e: React.PointerEvent) => {\n    if (!cfgRef.current.draggable) return;\n    dragRef.current = {\n      y: e.clientY,\n      start: targetRef.current,\n      id: e.pointerId,\n    };\n    dragMovedRef.current = false;\n    setIsDragging(true);\n  }, []);\n\n  const handlePointerMove = useCallback(\n    (e: React.PointerEvent) => {\n      const drag = dragRef.current;\n      if (!drag) return;\n      const dy = e.clientY - drag.y;\n      if (!dragMovedRef.current && Math.abs(dy) > 4) {\n        dragMovedRef.current = true;\n        rootRef.current?.setPointerCapture(drag.id);\n      }\n      if (dragMovedRef.current)\n        applyTarget(drag.start - dy / cfgRef.current.rowH, false);\n    },\n    [applyTarget]\n  );\n\n  const handlePointerEnd = useCallback(() => {\n    if (!dragRef.current) return;\n    dragRef.current = null;\n    setIsDragging(false);\n    if (dragMovedRef.current) applyTarget(targetRef.current, true);\n  }, [applyTarget]);\n\n  const handleItemClick = useCallback(\n    (index: number) => {\n      if (dragMovedRef.current) return;\n      const cfg = cfgRef.current;\n      const cur = targetRef.current;\n      let d = index - (((cur % cfg.count) + cfg.count) % cfg.count);\n      if (cfg.loop && cfg.count > 1) {\n        if (d > cfg.count / 2) d -= cfg.count;\n        else if (d < -cfg.count / 2) d += cfg.count;\n      }\n      applyTarget(cur + d, true);\n    },\n    [applyTarget]\n  );\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      let delta = null;\n      if (e.key === \"ArrowUp\" || e.key === \"ArrowLeft\") delta = -1;\n      else if (e.key === \"ArrowDown\" || e.key === \"ArrowRight\") delta = 1;\n      if (delta == null) return;\n      e.preventDefault();\n      applyTarget(Math.round(targetRef.current) + delta, true);\n    },\n    [applyTarget]\n  );\n\n  useEffect(() => {\n    applyTarget(targetRef.current, false);\n  }, [\n    items,\n    fontSize,\n    spacing,\n    curve,\n    tilt,\n    blur,\n    fade,\n    minOpacity,\n    side,\n    loop,\n    smoothing,\n    applyTarget,\n  ]);\n\n  useEffect(\n    () => () => {\n      if (rafRef.current != null) cancelAnimationFrame(rafRef.current);\n      rafRef.current = null;\n      audioRef.current?.pause();\n    },\n    []\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"listbox\"\n      tabIndex={0}\n      aria-label=\"Option wheel\"\n      className={cn(\n        \"relative w-full h-full overflow-hidden select-none touch-none outline-none\",\n        isDragging ? \"cursor-grabbing\" : \"cursor-grab\",\n        className\n      )}\n      style={{\n        ...style,\n        \"--ow-text-color\": textColor,\n        \"--ow-active-color\": activeColor,\n        \"--ow-font-size\": `${fontSize}rem`,\n        \"--ow-inset\": `${inset}px`,\n      } as any}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={handlePointerEnd}\n      onPointerCancel={handlePointerEnd}\n      onKeyDown={handleKeyDown}\n      {...props}\n    >\n      {items.map((label, index) => (\n        <div\n          key={`${label}-${index}`}\n          ref={(el) => {\n            itemRefs.current[index] = el;\n          }}\n          role=\"option\"\n          aria-selected={selectedIndex === index}\n          className={cn(\n            \"absolute top-1/2 whitespace-nowrap leading-none will-change-[transform,opacity,filter] cursor-pointer\",\n            side === \"right\" ? \"right-[var(--ow-inset)] origin-right\" : \"left-[var(--ow-inset)] origin-left\",\n            selectedIndex === index ? \"font-medium\" : \"font-extralight\"\n          )}\n          style={{\n            fontSize: \"var(--ow-font-size)\",\n            color: \"color-mix(in srgb, var(--ow-active-color) calc(var(--ow-p, 0) * 100%), var(--ow-text-color))\",\n          }}\n          onClick={() => handleItemClick(index)}\n        >\n          {label}\n        </div>\n      ))}\n    </div>\n  );\n}",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}