{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drum-picker",
  "title": "Drum Picker",
  "description": "A spinning wheel for picking one option. It coasts when flicked and always lands on a row.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://spotcheckui.com/r/spotcheck-base.json",
    "https://spotcheckui.com/r/use-flick.json"
  ],
  "files": [
    {
      "path": "registry/spotcheck/components/spotcheck/drum-picker.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { motion, type MotionValue, useTransform } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useFlick } from \"@/registry/spotcheck/hooks/use-flick\"\n\nconst ROW_HEIGHT = 42\nconst ROW_ANGLE = 22\nconst RADIUS = Math.round(ROW_HEIGHT / 2 / Math.tan(((ROW_ANGLE / 2) * Math.PI) / 180))\nconst FADE_PER_ROW = 0.26\nconst LAST_LEGIBLE_ROW = 2\nconst WHEEL_STEP = 36\n\nexport type DrumOption = { value: string; label: string }\n\n/** The row a wheel turn lands on, or null at the ends so the page can scroll again. */\nexport function wheelTarget(index: number, deltaY: number, count: number) {\n  const next = index + Math.sign(deltaY)\n  return next < 0 || next >= count || deltaY === 0 ? null : next\n}\n\n/** Rows fade as they turn away, and go by the row where they'd be too faint to read. */\nexport function rowOpacity(distance: number) {\n  const faintest = 1 - LAST_LEGIBLE_ROW * FADE_PER_ROW\n  if (distance <= LAST_LEGIBLE_ROW) return 1 - distance * FADE_PER_ROW\n  return Math.max(0, faintest * (LAST_LEGIBLE_ROW + 1 - distance))\n}\n\ntype DrumRowProps = {\n  id: string\n  index: number\n  label: string\n  selected: boolean\n  position: MotionValue<number>\n}\n\nfunction DrumRow({ id, index, label, selected, position }: DrumRowProps) {\n  const opacity = useTransform(position, (value) => rowOpacity(Math.abs(value - index)))\n\n  return (\n    <motion.div\n      id={id}\n      role=\"option\"\n      aria-selected={selected}\n      className={cn(\n        \"spotcheck-heading absolute inset-0 flex items-center justify-center text-[25px] backface-hidden select-none\",\n        selected && \"text-accent-ink\",\n      )}\n      style={{ opacity, transform: `rotateX(${-index * ROW_ANGLE}deg) translateZ(${RADIUS}px)` }}\n    >\n      {label}\n    </motion.div>\n  )\n}\n\ntype DrumPickerProps = {\n  /** Names the list for screen readers. */\n  label: string\n  options: DrumOption[]\n  /** The chosen option's value, when you control it. */\n  value?: string\n  /** The starting value, when the picker manages itself. */\n  defaultValue?: string\n  /** Runs when the wheel lands on a different row. */\n  onValueChange?: (value: string) => void\n  /** Added to the outer element. */\n  className?: string\n}\n\nexport function DrumPicker({ label, options, value, defaultValue, onValueChange, className }: DrumPickerProps) {\n  const [internal, setInternal] = React.useState(defaultValue ?? options[0]?.value)\n  const current = value ?? internal\n  const index = Math.max(\n    0,\n    options.findIndex((option) => option.value === current),\n  )\n  const count = options.length\n  const baseId = React.useId()\n  const wheel = React.useRef<HTMLDivElement>(null)\n  const turned = React.useRef(0)\n\n  const change = (next: number) => {\n    const option = options[next]\n    if (!option) return\n    setInternal(option.value)\n    onValueChange?.(option.value)\n  }\n\n  const { position, settle, bind } = useFlick({\n    count,\n    index,\n    onIndexChange: change,\n    axis: \"y\",\n    step: ROW_HEIGHT,\n    onTap: (event, from) => {\n      const box = event.currentTarget.getBoundingClientRect()\n      settle(Math.round(from + (event.clientY - (box.top + box.height / 2)) / ROW_HEIGHT))\n    },\n  })\n  const rotateX = useTransform(position, (value) => value * ROW_ANGLE)\n\n  React.useEffect(() => {\n    const element = wheel.current\n    if (!element) return\n    // React's wheel listener is passive, and this one has to be able to cancel the page scroll.\n    const onWheel = (event: WheelEvent) => {\n      const next = wheelTarget(index, event.deltaY, count)\n      if (next === null) {\n        turned.current = 0\n        return\n      }\n      event.preventDefault()\n      turned.current += event.deltaY\n      if (Math.abs(turned.current) < WHEEL_STEP) return\n      turned.current = 0\n      settle(next)\n    }\n    element.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => element.removeEventListener(\"wheel\", onWheel)\n  }, [count, index, settle])\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const moves: Record<string, number> = { ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: count - 1 }\n    const next = moves[event.key]\n    if (next === undefined) return\n    event.preventDefault()\n    settle(next)\n  }\n\n  return (\n    // The padding is the gutter: the wheel can't share vertical swipes, so phones scroll beside it.\n    <div className={cn(\"min-w-0 pr-12 font-(family-name:--f-body) md:pr-0\", className)}>\n      <div\n        ref={wheel}\n        role=\"listbox\"\n        tabIndex={0}\n        aria-label={label}\n        aria-activedescendant={`${baseId}-${index}`}\n        className=\"group spotcheck-focus border-ink bg-raise relative h-65 cursor-grab touch-none overflow-hidden border-(length:--rule) perspective-[900px] active:cursor-grabbing\"\n        onKeyDown={onKeyDown}\n        {...bind}\n      >\n        <div className=\"border-ink group-focus-visible:bg-[color-mix(in_srgb,var(--accent-ink)_12%,transparent)] pointer-events-none absolute inset-x-0 top-1/2 -mt-[23px] h-[46px] border-y-(length:--rule)\" />\n        <motion.div\n          data-part=\"wheel\"\n          className=\"absolute inset-x-0 top-1/2 -mt-[21px] h-[42px] transform-3d\"\n          style={{ rotateX }}\n        >\n          {options.map((option, row) => (\n            <DrumRow\n              key={option.value}\n              id={`${baseId}-${row}`}\n              index={row}\n              label={option.label}\n              selected={row === index}\n              position={position}\n            />\n          ))}\n        </motion.div>\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "depth"
  ],
  "type": "registry:component"
}