{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"click-wheel","type":"registry:component","title":"Click Wheel","description":"The web component: an unstyled, iPod-style rotary input for React. Parts: Root, Ring, Rotor, Center. Zero dependencies.","dependencies":[],"files":[{"path":"components/click-wheel/index.ts","target":"components/click-wheel/index.ts","type":"registry:component","content":"export * as ClickWheel from \"./parts\";\nexport { haptic, hapticsSupported, HapticTap } from \"./haptics\";\n"},{"path":"components/click-wheel/parts.ts","target":"components/click-wheel/parts.ts","type":"registry:component","content":"export { Root, type RootProps, type InteractionSource, type ChangeDetails, type InteractionDetails } from \"./root\";\nexport { Ring, type RingProps } from \"./ring\";\nexport { Rotor, type RotorProps } from \"./rotor\";\nexport { Center, type CenterProps } from \"./center\";\nexport type { ClickWheelState as State } from \"./context\";\n"},{"path":"components/click-wheel/context.ts","target":"components/click-wheel/context.ts","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\n\nexport interface ClickWheelState {\n  /** The current value. */\n  value: number;\n  /** A pointer is holding the ring. */\n  dragging: boolean;\n  /** The ring is spinning on after a flick, with no hand on it. */\n  coasting: boolean;\n  /** Whether the wheel is disabled. */\n  disabled: boolean;\n}\n\nexport interface ClickWheelContextValue {\n  state: ClickWheelState;\n  min: number;\n  max: number;\n  step: number;\n  ringRef: React.RefObject<HTMLElement | null>;\n  centerRef: React.RefObject<HTMLElement | null>;\n  onPointerDown: (event: React.PointerEvent) => void;\n  onPointerMove: (event: React.PointerEvent) => void;\n  onPointerUp: (event: React.PointerEvent) => void;\n  onPointerCancel: (event: React.PointerEvent) => void;\n  onKeyDown: (event: React.KeyboardEvent) => void;\n}\n\nexport const ClickWheelContext = React.createContext<ClickWheelContextValue | null>(null);\n\nexport function useClickWheelContext(part: string): ClickWheelContextValue {\n  const ctx = React.useContext(ClickWheelContext);\n  if (!ctx) {\n    throw new Error(`ClickWheel.${part} must be rendered inside ClickWheel.Root.`);\n  }\n  return ctx;\n}\n"},{"path":"components/click-wheel/render.tsx","target":"components/click-wheel/render.tsx","type":"registry:component","content":"import * as React from \"react\";\n\n/** Props every part accepts, following the Base UI `render` / `className` / `style` contract. */\nexport type PartProps<State, E extends React.ElementType> = Omit<\n  React.ComponentPropsWithRef<E>,\n  \"className\" | \"style\"\n> & {\n  /** A class string, or a function of the part's state that returns one. */\n  className?: string | ((state: State) => string);\n  /** A style object, or a function of the part's state that returns one. */\n  style?: React.CSSProperties | ((state: State) => React.CSSProperties);\n  /**\n   * Replace the default element: pass an element to merge into, or a function\n   * that receives the part's props and state and returns an element.\n   */\n  render?:\n    | React.ReactElement<Record<string, unknown>>\n    | ((props: Record<string, unknown>, state: State) => React.ReactElement);\n};\n\ntype AnyProps = Record<string, unknown>;\n\nfunction mergeRefs<T>(...refs: Array<React.Ref<T> | undefined>): React.RefCallback<T> {\n  return (node) => {\n    const cleanups = refs.map((ref) => {\n      if (typeof ref === \"function\") {\n        const cleanup = ref(node);\n        return typeof cleanup === \"function\" ? cleanup : () => ref(null);\n      }\n      if (ref) {\n        ref.current = node;\n        return () => { ref.current = null; };\n      }\n    });\n    return () => { for (const cleanup of cleanups) cleanup?.(); };\n  };\n}\n\n/** Stable merged ref, so React does not detach and re-attach refs on every render. */\nexport function useMergedRefs<T>(\n  a: React.Ref<T> | undefined,\n  b: React.Ref<T> | undefined,\n): React.RefCallback<T> {\n  return React.useMemo(() => mergeRefs(a, b), [a, b]);\n}\n\n/**\n * Merge internal props with the user's. Handlers chain (user first), classes\n * join, styles merge, and any other external value wins.\n */\nexport function mergeProps(internal: AnyProps, external: AnyProps): AnyProps {\n  const out: AnyProps = { ...internal };\n  for (const key of Object.keys(external)) {\n    const a = internal[key];\n    const b = external[key];\n    if (b === undefined) continue;\n    if (key === \"className\") {\n      out.className = a ? `${a} ${b}` : b;\n    } else if (key === \"style\") {\n      out.style = { ...(a as object), ...(b as object) };\n    } else if (/^on[A-Z]/.test(key) && typeof a === \"function\" && typeof b === \"function\") {\n      out[key] = (...args: unknown[]) => {\n        b(...args);\n        a(...args);\n      };\n    } else {\n      out[key] = b;\n    }\n  }\n  return out;\n}\n\n/**\n * Render one part. `internal` holds the props the part needs to function;\n * `external` is what the consumer passed, including `render`.\n */\nexport function useRenderPart<State>(\n  tag: keyof React.JSX.IntrinsicElements,\n  state: State,\n  external: PartProps<State, React.ElementType>,\n  internal: AnyProps,\n): React.ReactElement {\n  const { render, className, style, ...rest } = external;\n  const resolved: AnyProps = {\n    ...rest,\n    className: typeof className === \"function\" ? className(state) : className,\n    style: typeof style === \"function\" ? style(state) : style,\n  };\n  const props = mergeProps(internal, resolved);\n  const ref = useMergedRefs(\n    props.ref as React.Ref<unknown>,\n    typeof render === \"object\" ? render.props.ref as React.Ref<unknown> : undefined,\n  );\n  if (typeof render === \"function\") return render({ ...props, ref }, state);\n  if (render) return React.cloneElement(render, { ...mergeProps(props, render.props), ref });\n  return React.createElement(tag, { ...props, ref });\n}\n"},{"path":"components/click-wheel/haptics.tsx","target":"components/click-wheel/haptics.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/*\n  Haptics on the web, as of late 2026:\n\n  - Android (Chrome, Firefox, Samsung Internet): `navigator.vibrate`.\n  - iOS 17.4 – 26.4: Safari has no Vibration API, but toggling a\n    `<input type=\"checkbox\" switch>` through its <label> fires the Taptic\n    engine, even from script.\n  - iOS 26.5+: script-driven toggles no longer vibrate. Only a real tap on a\n    switch does, so render <HapticTap /> inside a tappable element.\n  - Desktop: nothing vibrates. `navigator.vibrate` exists in Chrome and no-ops.\n*/\n\nfunction isIOS(): boolean {\n  return typeof CSS !== \"undefined\" && CSS.supports(\"-webkit-touch-callout\", \"none\");\n}\n\n/** True when this browser has any path to a haptic pulse. */\nexport function hapticsSupported(): boolean {\n  if (typeof navigator === \"undefined\") return false;\n  return typeof navigator.vibrate === \"function\" || isIOS();\n}\n\nlet iosSwitch: HTMLLabelElement | null = null;\n\nfunction getIOSSwitch(): HTMLLabelElement {\n  if (iosSwitch) return iosSwitch;\n  const label = document.createElement(\"label\");\n  label.setAttribute(\"aria-hidden\", \"true\");\n  label.style.cssText =\n    \"position:fixed;top:0;left:0;width:1px;height:1px;overflow:hidden;opacity:0;pointer-events:none;\";\n  const input = document.createElement(\"input\");\n  input.type = \"checkbox\";\n  input.setAttribute(\"switch\", \"\");\n  input.tabIndex = -1;\n  label.appendChild(input);\n  document.body.appendChild(label);\n  iosSwitch = label;\n  return label;\n}\n\n/**\n * One short haptic pulse. `durationMs` only applies to the Vibration API;\n * iOS always produces its own fixed tick.\n */\nexport function haptic(durationMs = 4): void {\n  if (typeof navigator === \"undefined\") return;\n  if (typeof navigator.vibrate === \"function\") {\n    navigator.vibrate(durationMs);\n    return;\n  }\n  if (isIOS()) getIOSSwitch().click();\n}\n\nconst TAP_STYLE: React.CSSProperties = {\n  position: \"absolute\",\n  inset: 0,\n  width: \"100%\",\n  height: \"100%\",\n  margin: 0,\n  opacity: 0,\n  clipPath: \"inset(0 round 999px)\",\n  WebkitTapHighlightColor: \"transparent\",\n};\n\n/**\n * An invisible switch that covers its parent so a real tap ticks on iOS,\n * including 26.5+. Place it inside a positioned, round, tappable element;\n * the click still bubbles to the parent. Does nothing elsewhere.\n */\nexport function HapticTap(props: React.ComponentProps<\"input\">) {\n  const { style, ...rest } = props;\n  return (\n    <input\n      type=\"checkbox\"\n      aria-hidden=\"true\"\n      tabIndex={-1}\n      {...{ switch: \"\" }}\n      {...rest}\n      style={{ ...TAP_STYLE, ...style }}\n    />\n  );\n}\n"},{"path":"components/click-wheel/root.tsx","target":"components/click-wheel/root.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { ClickWheelContext, type ClickWheelContextValue, type ClickWheelState } from \"./context\";\nimport {\n  angleAt,\n  arcDelta,\n  clamp,\n  coastStep,\n  DEFAULT_DECELERATION_RATE,\n  degreesToUnits,\n  detentCrossings,\n  HUB_DEAD_ZONE,\n  keyDelta,\n  MIN_VELOCITY,\n  releaseVelocity,\n  type Sample,\n  stepValue,\n  unitsToDegrees,\n  VELOCITY_WINDOW_MS,\n  WHEEL_DEG_PER_PX,\n} from \"./core\";\nimport { haptic } from \"./haptics\";\nimport { useRenderPart, useMergedRefs, type PartProps } from \"./render\";\n\nexport type InteractionSource = \"pointer\" | \"wheel\" | \"keyboard\";\n\nexport interface ChangeDetails {\n  source: InteractionSource;\n}\n\nexport interface InteractionDetails extends ChangeDetails {\n  /** True when disabled, cancelled by the browser, or unmounted before committing. */\n  cancelled: boolean;\n}\n\nexport interface RootProps extends Omit<PartProps<ClickWheelState, \"div\">, \"defaultValue\"> {\n  /** Controlled value. */\n  value?: number;\n  /** Starting value when uncontrolled. */\n  defaultValue?: number;\n  /** Fires on every value change while dragging, coasting, scrolling or keying. */\n  onValueChange?: (value: number, details: ChangeDetails) => void;\n  /** Fires once when an interaction ends. With inertia, that is when the wheel settles. */\n  onValueCommitted?: (value: number, details: ChangeDetails) => void;\n  /** Covers pointer, scroll and keyboard interactions, including any coast. */\n  onInteractionChange?: (active: boolean, details: InteractionDetails) => void;\n  /** Fires when a pointer takes hold of the ring and when it lets go. Use `onInteractionChange` to include coasting and cancellation. */\n  onDraggingChange?: (dragging: boolean) => void;\n  /** Fires each time the value crosses a detent. */\n  onTick?: (direction: 1 | -1) => void;\n  min?: number;\n  max?: number;\n  /** Granularity of emitted values; also the arrow-key step. */\n  step?: number;\n  /** The gearing: how many units one full revolution covers. */\n  unitsPerTurn?: number;\n  /** Units between detents (haptic pulse + onTick). 0 turns detents off. */\n  detent?: number;\n  /** Pulse the haptic motor when crossing detents; coalesce crossings within one update. */\n  haptics?: boolean;\n  /** Keep spinning after a flick and slow down like an iOS scroll. */\n  inertia?: boolean;\n  /** Velocity kept per millisecond while coasting. 0.998 is iOS normal; 0.99 stops fast. */\n  decelerationRate?: number;\n  disabled?: boolean;\n  /** Renders a hidden input with this name, for forms. */\n  name?: string;\n}\n\n/**\n * A rotary slider. Drag around the ring; one revolution moves the value by\n * `unitsPerTurn`, so range and precision are set by gearing instead of by\n * how many pixels of screen the control happens to span.\n */\nexport function Root(props: RootProps) {\n  const {\n    value: valueProp,\n    defaultValue,\n    onValueChange,\n    onValueCommitted,\n    onInteractionChange,\n    onDraggingChange,\n    onTick,\n    min = 0,\n    max = 100,\n    step = 1,\n    unitsPerTurn = 100,\n    detent = 0,\n    haptics = true,\n    inertia = false,\n    decelerationRate = DEFAULT_DECELERATION_RATE,\n    disabled = false,\n    name,\n    children,\n    ref: refProp,\n    ...rest\n  } = props;\n\n  const [internalValue, setInternalValue] = React.useState(() =>\n    clamp(defaultValue ?? min, min, max),\n  );\n  const value = clamp(valueProp ?? internalValue, min, max);\n  const [dragging, setDragging] = React.useState(false);\n  const [coasting, setCoasting] = React.useState(false);\n  const [progressRevision, syncProgress] = React.useReducer((revision: number) => revision + 1, 0);\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const ringRef = React.useRef<HTMLElement>(null);\n  const centerRef = React.useRef<HTMLElement>(null);\n  const mergedRootRef = useMergedRefs(rootRef, refProp);\n\n  // Gesture state lives outside React: pointermove must not wait for renders.\n  const g = React.useRef({\n    pointerId: -1,\n    cx: 0,\n    cy: 0,\n    lastAngle: 0,\n    float: 0,\n    rotation: 0,\n    emitted: NaN,\n    dragging: false,\n    coasting: false,\n    wheeling: false,\n    interaction: null as InteractionSource | null,\n    wheelTimer: 0,\n    samples: [] as Sample[],\n    velocity: 0,\n    frame: 0,\n    lastFrame: 0,\n    onWindowBlur: null as null | (() => void),\n  });\n\n  // Handlers and the coast loop read props from here, so a frame that runs\n  // between renders never sees stale gearing or callbacks.\n  const propsSnapshot = {\n    value,\n    controlled: valueProp !== undefined,\n    min,\n    max,\n    step,\n    unitsPerTurn,\n    detent,\n    haptics,\n    inertia,\n    decelerationRate,\n    disabled,\n    onValueChange,\n    onValueCommitted,\n    onInteractionChange,\n    onDraggingChange,\n    onTick,\n  };\n  const latest = React.useRef(propsSnapshot);\n  React.useLayoutEffect(() => {\n    latest.current = propsSnapshot;\n  });\n\n  const beginInteraction = (source: InteractionSource) => {\n    if (g.current.interaction) return;\n    g.current.interaction = source;\n    latest.current.onInteractionChange?.(true, { source, cancelled: false });\n  };\n\n  const endInteraction = (cancelled = false) => {\n    const source = g.current.interaction;\n    if (!source) return;\n    g.current.interaction = null;\n    latest.current.onInteractionChange?.(false, { source, cancelled });\n    syncProgress();\n  };\n\n  const emit = (raw: number, commit: boolean) => {\n    const L = latest.current;\n    const next = stepValue(raw, L.min, L.max, L.step);\n    const details = { source: g.current.interaction ?? \"keyboard\" };\n    if (next !== g.current.emitted) {\n      g.current.emitted = next;\n      if (!L.controlled) setInternalValue(next);\n      L.onValueChange?.(next, details);\n    }\n    if (commit) {\n      L.onValueCommitted?.(next, details);\n      endInteraction();\n    }\n  };\n\n  const spin = (deltaDeg: number) => {\n    g.current.rotation += deltaDeg;\n    rootRef.current?.style.setProperty(\"--click-wheel-rotation\", `${g.current.rotation}deg`);\n  };\n\n  /**\n   * The progress variables. Written from the continuous position during a\n   * turn, so arcs and grooves move like a needle, not in value steps.\n   */\n  const writeProgress = (units: number) => {\n    const L = latest.current;\n    const root = rootRef.current;\n    if (!root) return;\n    const fraction = L.max > L.min ? (units - L.min) / (L.max - L.min) : 0;\n    root.style.setProperty(\"--click-wheel-fraction\", String(fraction));\n    root.style.setProperty(\"--click-wheel-turns\", String((units - L.min) / L.unitsPerTurn));\n  };\n\n  const crossDetents = (prev: number, next: number) => {\n    const L = latest.current;\n    const crossings = detentCrossings(prev, next, L.min, L.detent);\n    if (crossings === 0) return;\n    if (L.haptics) haptic();\n    const direction = crossings > 0 ? 1 : -1;\n    if (L.onTick) {\n      for (let i = 0; i < Math.abs(crossings); i++) L.onTick(direction);\n    }\n  };\n\n  /** Turn the ring by an angle: rotate the rotor, move the value, tick detents. */\n  const turnBy = (deltaDeg: number) => {\n    const L = latest.current;\n    const prev = g.current.float;\n    const next = clamp(prev + degreesToUnits(deltaDeg, L.unitsPerTurn), L.min, L.max);\n    spin(unitsToDegrees(next - prev, L.unitsPerTurn));\n    g.current.float = next;\n    writeProgress(next);\n    crossDetents(prev, next);\n    emit(next, false);\n  };\n\n  const beginDrag = () => {\n    if (g.current.dragging) return;\n    g.current.dragging = true;\n    setDragging(true);\n    latest.current.onDraggingChange?.(true);\n  };\n\n  const endDrag = () => {\n    if (!g.current.dragging) return;\n    g.current.dragging = false;\n    setDragging(false);\n    latest.current.onDraggingChange?.(false);\n  };\n\n  const setCoast = (next: boolean) => {\n    if (g.current.coasting === next) return;\n    g.current.coasting = next;\n    setCoasting(next);\n  };\n\n  /** The interaction is over: the pointer lifted with no flick, or the coast ran out. */\n  const settle = () => {\n    window.clearTimeout(g.current.wheelTimer);\n    g.current.wheeling = false;\n    if (g.current.onWindowBlur) {\n      window.removeEventListener(\"blur\", g.current.onWindowBlur);\n      g.current.onWindowBlur = null;\n    }\n    g.current.velocity = 0;\n    endDrag();\n    setCoast(false);\n    emit(g.current.float, true);\n  };\n\n  const cancelCoast = () => {\n    if (!g.current.frame) return;\n    cancelAnimationFrame(g.current.frame);\n    g.current.frame = 0;\n    g.current.velocity = 0;\n    setCoast(false);\n  };\n\n  const cancelInteraction = () => {\n    const s = g.current;\n    if (!s.interaction) return;\n    const pointerId = s.pointerId;\n    s.pointerId = -1;\n    cancelCoast();\n    window.clearTimeout(s.wheelTimer);\n    s.wheeling = false;\n    if (s.onWindowBlur) {\n      window.removeEventListener(\"blur\", s.onWindowBlur);\n      s.onWindowBlur = null;\n    }\n    endDrag();\n    if (pointerId !== -1 && ringRef.current?.hasPointerCapture?.(pointerId)) {\n      ringRef.current.releasePointerCapture(pointerId);\n    }\n    endInteraction(true);\n  };\n\n  /** Stop a coast from the outside (scroll, keys): the wheel settles where it is. */\n  const interrupt = (includeWheel = false) => {\n    if (!g.current.frame && !(includeWheel && g.current.wheeling)) return;\n    cancelCoast();\n    settle();\n  };\n\n  /** Spin on with `velocity` in degrees per millisecond, losing speed like an iOS scroll. */\n  const coast = (velocity: number, startedAt: number) => {\n    g.current.velocity = velocity;\n    g.current.lastFrame = startedAt;\n    endDrag();\n    setCoast(true);\n    const frame = (now: number) => {\n      const L = latest.current;\n      const s = g.current;\n      if (L.disabled) {\n        cancelInteraction();\n        return;\n      }\n      const dt = Math.min(now - s.lastFrame, 64);\n      s.lastFrame = now;\n      const stepped = coastStep(s.velocity, dt, L.decelerationRate);\n      s.velocity = stepped.velocity;\n      turnBy(stepped.delta);\n      const atEnd = (s.float <= L.min && s.velocity < 0) || (s.float >= L.max && s.velocity > 0);\n      if (Math.abs(s.velocity) < MIN_VELOCITY || atEnd) {\n        s.frame = 0;\n        settle();\n        return;\n      }\n      s.frame = requestAnimationFrame(frame);\n    };\n    g.current.frame = requestAnimationFrame(frame);\n  };\n\n  /** Move the value by whole units: keyboard and programmatic steps. */\n  const nudge = (deltaUnits: number) => {\n    if (g.current.dragging) cancelInteraction();\n    const wasInteracting = g.current.interaction !== null;\n    const previous = wasInteracting ? g.current.emitted : latest.current.value;\n    interrupt(true);\n    const L = latest.current;\n    beginInteraction(\"keyboard\");\n    g.current.emitted = previous;\n    const next = stepValue(previous + deltaUnits, L.min, L.max, L.step);\n    g.current.float = next;\n    spin(unitsToDegrees(next - previous, L.unitsPerTurn));\n    writeProgress(next);\n    crossDetents(previous, next);\n    emit(next, true);\n  };\n\n  const angleOf = (e: { clientX: number; clientY: number }) =>\n    angleAt(g.current.cx, g.current.cy, e.clientX, e.clientY);\n\n  const onPointerDown = (e: React.PointerEvent) => {\n    const L = latest.current;\n    if (L.disabled || g.current.pointerId !== -1 || e.button !== 0) return;\n    const ring = ringRef.current;\n    if (!ring) return;\n    if (centerRef.current?.contains(e.target as Node)) return;\n    if (g.current.wheeling) interrupt(true);\n    const rect = ring.getBoundingClientRect();\n    const cx = rect.left + rect.width / 2;\n    const cy = rect.top + rect.height / 2;\n    // The angle is unstable right at the hub; ignore presses there.\n    if (Math.hypot(e.clientX - cx, e.clientY - cy) < Math.min(rect.width, rect.height) * HUB_DEAD_ZONE) {\n      return;\n    }\n    e.preventDefault();\n    try {\n      ring.setPointerCapture(e.pointerId);\n    } catch {\n      // Synthetic events in tests carry ids the browser does not know.\n    }\n    ring.focus({ preventScroll: true });\n    // A touch grabs a spinning wheel: its momentum is gone, a hand is on it.\n    const wasCoasting = Boolean(g.current.frame);\n    cancelCoast();\n    Object.assign(g.current, {\n      pointerId: e.pointerId,\n      cx,\n      cy,\n      float: wasCoasting ? g.current.float : L.value,\n      emitted: wasCoasting ? g.current.emitted : L.value,\n    });\n    g.current.lastAngle = angleOf(e);\n    g.current.samples = [{ t: e.timeStamp, rotation: g.current.rotation }];\n    // A release outside the window never reaches the ring; the window going\n    // blurry is the signal that the pointer is gone.\n    const onWindowBlur = () => {\n      if (g.current.pointerId === -1) return;\n      cancelInteraction();\n    };\n    if (g.current.onWindowBlur) window.removeEventListener(\"blur\", g.current.onWindowBlur);\n    g.current.onWindowBlur = onWindowBlur;\n    window.addEventListener(\"blur\", onWindowBlur);\n    beginInteraction(\"pointer\");\n    beginDrag();\n  };\n\n  const onPointerMove = (e: React.PointerEvent) => {\n    if (latest.current.disabled || g.current.pointerId !== e.pointerId) return;\n    const angle = angleOf(e);\n    const delta = arcDelta(g.current.lastAngle, angle);\n    g.current.lastAngle = angle;\n    turnBy(delta);\n    const samples = g.current.samples;\n    samples.push({ t: e.timeStamp, rotation: g.current.rotation });\n    while (samples.length > 1 && e.timeStamp - samples[0].t > VELOCITY_WINDOW_MS) samples.shift();\n  };\n\n  const onPointerUp = (e: React.PointerEvent) => {\n    if (g.current.pointerId !== e.pointerId) return;\n    g.current.pointerId = -1;\n    const L = latest.current;\n    const velocity = L.inertia && !L.disabled ? releaseVelocity(g.current.samples, e.timeStamp) : 0;\n    if (Math.abs(velocity) >= MIN_VELOCITY) {\n      coast(velocity, e.timeStamp); // the hand is off; the wheel coasts until it settles\n      return;\n    }\n    settle();\n  };\n\n  // Fires on cancel, and whenever the browser drops the capture mid-turn.\n  // After a normal release the pointer id is already cleared, so this no-ops.\n  const onPointerCancel = (e: React.PointerEvent) => {\n    if (g.current.pointerId !== e.pointerId) return;\n    cancelInteraction();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const L = latest.current;\n    if (L.disabled) return;\n    const deltaUnits = keyDelta(e.key, {\n      ...L,\n      value: g.current.interaction && !g.current.dragging ? g.current.emitted : L.value,\n    });\n    if (deltaUnits === null) return;\n    e.preventDefault();\n    nudge(deltaUnits);\n  };\n\n  // A scroll gesture over the wheel turns it. Listens on the root so the\n  // center is covered too; non-passive so the page does not scroll.\n  const onWheel = React.useEffectEvent((e: WheelEvent) => {\n    const L = latest.current;\n    if (L.disabled) return;\n    e.preventDefault();\n    if (g.current.dragging) return;\n    interrupt();\n    const scale = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1;\n    const px = (Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY) * scale;\n    if (!g.current.wheeling) {\n      g.current.wheeling = true;\n      beginInteraction(\"wheel\");\n      if (g.current.pointerId === -1) {\n        g.current.float = L.value;\n        g.current.emitted = L.value;\n      }\n    }\n    turnBy(px * WHEEL_DEG_PER_PX);\n    window.clearTimeout(g.current.wheelTimer);\n    g.current.wheelTimer = window.setTimeout(() => {\n      g.current.wheeling = false;\n      if (g.current.pointerId === -1) emit(g.current.float, true);\n    }, 150);\n  });\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const gesture = g.current;\n    if (!root) return;\n    root.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => {\n      root.removeEventListener(\"wheel\", onWheel);\n      window.clearTimeout(gesture.wheelTimer);\n      if (gesture.frame) cancelAnimationFrame(gesture.frame);\n      if (gesture.onWindowBlur) window.removeEventListener(\"blur\", gesture.onWindowBlur);\n      if (gesture.dragging) latest.current.onDraggingChange?.(false);\n      if (gesture.interaction) {\n        latest.current.onInteractionChange?.(false, { source: gesture.interaction, cancelled: true });\n      }\n      gesture.interaction = null;\n      gesture.dragging = false;\n      gesture.coasting = false;\n      gesture.wheeling = false;\n      gesture.pointerId = -1;\n      gesture.frame = 0;\n    };\n  }, []);\n\n  const cancelWhenDisabled = React.useEffectEvent(() => {\n    if (disabled) cancelInteraction();\n  });\n  React.useLayoutEffect(() => {\n    cancelWhenDisabled();\n  }, [disabled]);\n\n  // Keep continuous gesture progress until it settles, then use the accepted value.\n  React.useLayoutEffect(() => {\n    if (g.current.interaction) return;\n    const root = rootRef.current;\n    if (!root) return;\n    root.style.setProperty(\"--click-wheel-fraction\", String(max > min ? (value - min) / (max - min) : 0));\n    root.style.setProperty(\"--click-wheel-turns\", String((value - min) / unitsPerTurn));\n  }, [value, min, max, unitsPerTurn, dragging, coasting, progressRevision]);\n\n  // The first paint gets the variables inline; after that they are written\n  // directly, so React never overwrites a position mid-turn.\n  const [initialVars] = React.useState(() => ({\n    \"--click-wheel-fraction\": max > min ? (value - min) / (max - min) : 0,\n    \"--click-wheel-turns\": (value - min) / unitsPerTurn,\n  }) as React.CSSProperties);\n\n  const state: ClickWheelState = { value, dragging, coasting, disabled };\n  const context: ClickWheelContextValue = {\n    state,\n    min,\n    max,\n    step,\n    ringRef,\n    centerRef,\n    onPointerDown,\n    onPointerMove,\n    onPointerUp,\n    onPointerCancel,\n    onKeyDown,\n  };\n\n  return (\n    <ClickWheelContext.Provider value={context}>\n      {useRenderPart(\n        \"div\",\n        state,\n        {\n          ...rest,\n          children: (\n            <>\n              {children}\n              {name ? <input type=\"hidden\" name={name} value={value} disabled={disabled} /> : null}\n            </>\n          ),\n        },\n        {\n          ref: mergedRootRef,\n          \"data-dragging\": dragging ? \"\" : undefined,\n          \"data-coasting\": coasting ? \"\" : undefined,\n          \"data-disabled\": disabled ? \"\" : undefined,\n          style: initialVars,\n        },\n      )}\n    </ClickWheelContext.Provider>\n  );\n}\n"},{"path":"components/click-wheel/ring.tsx","target":"components/click-wheel/ring.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { useClickWheelContext, type ClickWheelState } from \"./context\";\nimport { stepValue } from \"./core\";\nimport { useRenderPart, useMergedRefs, type PartProps } from \"./render\";\n\nexport interface RingProps extends PartProps<ClickWheelState, \"div\"> {\n  /** Formats the value for assistive tech, e.g. seconds to \"1 min 20 sec\". */\n  getAriaValueText?: (value: number) => string;\n}\n\n// Needed for pointer capture on touch screens; everything else is yours.\nconst RING_STYLE: React.CSSProperties = {\n  touchAction: \"none\",\n  userSelect: \"none\",\n  WebkitUserSelect: \"none\",\n  WebkitTouchCallout: \"none\",\n};\n\n/**\n * The interactive ring. Drag around it, scroll over it, or focus it and use\n * the keyboard. Exposed to assistive tech as a slider.\n */\nexport function Ring(props: RingProps) {\n  const { getAriaValueText, ref: refProp, ...rest } = props;\n  const ctx = useClickWheelContext(\"Ring\");\n  const { state, min, max, step } = ctx;\n  const spoken = stepValue(state.value, min, max, step);\n  const ref = useMergedRefs(ctx.ringRef, refProp as React.Ref<HTMLElement>);\n\n  return useRenderPart(\"div\", state, rest, {\n    ref,\n    role: \"slider\",\n    tabIndex: state.disabled ? -1 : 0,\n    \"aria-valuemin\": min,\n    \"aria-valuemax\": max,\n    \"aria-valuenow\": spoken,\n    \"aria-valuetext\": getAriaValueText?.(spoken),\n    \"aria-disabled\": state.disabled || undefined,\n    \"data-dragging\": state.dragging ? \"\" : undefined,\n    \"data-coasting\": state.coasting ? \"\" : undefined,\n    \"data-disabled\": state.disabled ? \"\" : undefined,\n    style: RING_STYLE,\n    onPointerDown: ctx.onPointerDown,\n    onPointerMove: ctx.onPointerMove,\n    onPointerUp: ctx.onPointerUp,\n    onPointerCancel: ctx.onPointerCancel,\n    onLostPointerCapture: ctx.onPointerCancel,\n    onKeyDown: ctx.onKeyDown,\n  });\n}\n"},{"path":"components/click-wheel/rotor.tsx","target":"components/click-wheel/rotor.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useClickWheelContext, type ClickWheelState } from \"./context\";\nimport { useRenderPart, type PartProps } from \"./render\";\n\nexport type RotorProps = PartProps<ClickWheelState, \"div\">;\n\n// `will-change` keeps the rotor on its own compositor layer, so a turn moves a\n// cached texture instead of repainting it on every pointer sample.\nconst ROTOR_STYLE = {\n  rotate: \"var(--click-wheel-rotation, 0deg)\",\n  willChange: \"transform\",\n} as const;\n\n/**\n * A decorative element that rotates 1:1 with the finger, like the texture of\n * a real wheel, stopping at the value bounds. Give it a texture; it does the turning.\n */\nexport function Rotor(props: RotorProps) {\n  const { state } = useClickWheelContext(\"Rotor\");\n  return useRenderPart(\"div\", state, props, {\n    \"aria-hidden\": true,\n    \"data-dragging\": state.dragging ? \"\" : undefined,\n    \"data-coasting\": state.coasting ? \"\" : undefined,\n    \"data-disabled\": state.disabled ? \"\" : undefined,\n    style: ROTOR_STYLE,\n  });\n}\n"},{"path":"components/click-wheel/center.tsx","target":"components/click-wheel/center.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { useClickWheelContext, type ClickWheelState } from \"./context\";\nimport { useRenderPart, useMergedRefs, type PartProps } from \"./render\";\n\nexport type CenterProps = PartProps<ClickWheelState, \"button\">;\n\n/**\n * The center button. Presses here never start a turn. Pass `onClick` and an\n * `aria-label`; render it as a `div` for a plain hub.\n */\nexport function Center(props: CenterProps) {\n  const { ref: refProp, ...rest } = props;\n  const ctx = useClickWheelContext(\"Center\");\n  const { state } = ctx;\n  const ref = useMergedRefs(ctx.centerRef, refProp as React.Ref<HTMLElement>);\n\n  const element = useRenderPart(\"button\", state, rest, {\n    ref,\n    disabled: state.disabled || undefined,\n    \"data-dragging\": state.dragging ? \"\" : undefined,\n    \"data-coasting\": state.coasting ? \"\" : undefined,\n    \"data-disabled\": state.disabled ? \"\" : undefined,\n  });\n  const merged = element.props as React.ComponentPropsWithRef<\"button\">;\n  const disabled = state.disabled || merged.disabled;\n  const button = element.type === \"button\" || typeof element.type !== \"string\";\n\n  return React.cloneElement(element, {\n    type: merged.type ?? (button ? \"button\" : undefined),\n    disabled: button ? disabled || undefined : undefined,\n    \"aria-disabled\": disabled || merged[\"aria-disabled\"],\n    \"data-disabled\": disabled ? \"\" : undefined,\n    tabIndex: disabled && !button ? -1 : merged.tabIndex,\n    onClick: (event: React.MouseEvent<HTMLButtonElement>) => {\n      if (disabled) {\n        event.preventDefault();\n        event.stopPropagation();\n        return;\n      }\n      merged.onClick?.(event);\n    },\n  } as React.ComponentPropsWithRef<\"button\">);\n}\n"},{"path":"components/click-wheel/core.ts","target":"components/click-wheel/core.ts","type":"registry:component","content":"export const DEG = 180 / Math.PI;\n/** How far a scroll gesture turns the ring: 900px of scrolling is one revolution. */\nexport const WHEEL_DEG_PER_PX = 0.4;\n/** Pointer samples younger than this feed the release velocity. */\nexport const VELOCITY_WINDOW_MS = 100;\n/** A finger that paused this long before lifting does not flick. */\nexport const RELEASE_PAUSE_MS = 60;\n/** Below this the coast is over, in degrees per millisecond. */\nexport const MIN_VELOCITY = 0.02;\n/** Velocity kept per millisecond while coasting; the iOS default. */\nexport const DEFAULT_DECELERATION_RATE = 0.998;\n/** Presses closer to the hub than this fraction of the size are ignored: the angle is unstable there. */\nexport const HUB_DEAD_ZONE = 0.05;\n\nexport interface Sample {\n  t: number;\n  rotation: number;\n}\n\nexport function clamp(v: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, v));\n}\n\n/** Angle of a point around a center, in degrees. */\nexport function angleAt(cx: number, cy: number, x: number, y: number): number {\n  return Math.atan2(y - cy, x - cx) * DEG;\n}\n\n/** Shortest signed arc from one angle to another, in degrees. */\nexport function arcDelta(from: number, to: number): number {\n  return ((to - from + 540) % 360) - 180;\n}\n\nexport function degreesToUnits(deg: number, unitsPerTurn: number): number {\n  return (deg / 360) * unitsPerTurn;\n}\n\nexport function unitsToDegrees(units: number, unitsPerTurn: number): number {\n  return (units / unitsPerTurn) * 360;\n}\n\n/** Snap a raw value to the step grid and the bounds. */\nexport function stepValue(raw: number, min: number, max: number, step: number): number {\n  if (raw <= min) return min;\n  if (raw >= max) return max;\n  const stepped = Math.round((raw - min) / step) * step + min;\n  return Number(clamp(stepped, min, max).toFixed(6));\n}\n\n/** 1 or -1 when moving from `prev` to `next` crosses a detent, else 0. */\nexport function detentCrossing(prev: number, next: number, min: number, detent: number): 1 | -1 | 0 {\n  return Math.sign(detentCrossings(prev, next, min, detent)) as 1 | -1 | 0;\n}\n\n/** Signed number of detent boundaries crossed in one update. */\nexport function detentCrossings(prev: number, next: number, min: number, detent: number): number {\n  if (detent <= 0 || prev === next) return 0;\n  const from = Math.floor((prev - min) / detent);\n  const to = Math.floor((next - min) / detent);\n  return to - from;\n}\n\n/**\n * One frame of coasting under exponential decay: the distance covered in\n * `dt` milliseconds, integrated exactly, and the velocity left afterwards.\n */\nexport function coastStep(\n  velocity: number,\n  dt: number,\n  decelerationRate: number,\n): { delta: number; velocity: number } {\n  const keep = Math.pow(decelerationRate, dt);\n  const delta = (velocity * (1 - keep)) / -Math.log(decelerationRate);\n  return { delta, velocity: velocity * keep };\n}\n\n/** Angular velocity at release, in degrees per millisecond, from recent samples. */\nexport function releaseVelocity(samples: Sample[], now: number): number {\n  const last = samples[samples.length - 1];\n  const first = samples[0];\n  if (!last || !first || last === first || now - last.t > RELEASE_PAUSE_MS) return 0;\n  const dt = last.t - first.t;\n  return dt > 0 ? (last.rotation - first.rotation) / dt : 0;\n}\n\n/**\n * Angular velocity, in degrees per millisecond, of a point at offset\n * (rx, ry) from the center moving at (vx, vy) pixels per millisecond.\n */\nexport function angularVelocity(rx: number, ry: number, vx: number, vy: number): number {\n  const r2 = rx * rx + ry * ry;\n  if (r2 === 0) return 0;\n  return ((rx * vy - ry * vx) / r2) * DEG;\n}\n\nexport interface KeyContext {\n  value: number;\n  min: number;\n  max: number;\n  step: number;\n  unitsPerTurn: number;\n}\n\n/** The change in units a keyboard key asks for, or null for keys the wheel ignores. */\nexport function keyDelta(key: string, ctx: KeyContext): number | null {\n  const page = Math.max(ctx.step, ctx.unitsPerTurn / 6);\n  if (key === \"ArrowRight\" || key === \"ArrowUp\") return ctx.step;\n  if (key === \"ArrowLeft\" || key === \"ArrowDown\") return -ctx.step;\n  if (key === \"PageUp\") return page;\n  if (key === \"PageDown\") return -page;\n  if (key === \"Home\") return ctx.min - ctx.value;\n  if (key === \"End\") return ctx.max - ctx.value;\n  return null;\n}\n"}],"docs":"Requires React 19.2 or newer. Import { ClickWheel } from \"@/components/click-wheel\". Docs: https://click-wheel.jackymo.me/docs.md"}