{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scrub-input",
  "title": "Scrub Input",
  "description": "Drag a number sideways to change it while the digits roll. Press it to type.",
  "registryDependencies": [
    "https://spotcheckui.com/r/spotcheck-base.json",
    "https://spotcheckui.com/r/roll-number.json"
  ],
  "files": [
    {
      "path": "registry/spotcheck/components/spotcheck/scrub-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { RollNumber } from \"@/registry/spotcheck/components/spotcheck/roll-number\"\n\nconst PIXELS_PER_STEP = 6\nconst LARGE_STEP = 10\n\nfunction decimalsOf(step: number) {\n  return (String(step).split(\".\")[1] ?? \"\").length\n}\n\n/** Rounds to the step and clamps to the range, without float noise. */\nexport function snapValue(value: number, min: number, max: number, step: number) {\n  const snapped = Number((Math.round(value / step) * step).toFixed(decimalsOf(step)))\n  return Math.min(max, Math.max(min, snapped))\n}\n\ntype ScrubInputProps = {\n  /** Names the control for screen readers and prints above the value. */\n  label: string\n  /** The current value, when you control it. */\n  value?: number\n  /** The starting value, when it manages itself. */\n  defaultValue?: number\n  onValueChange?: (value: number) => void\n  min?: number\n  max?: number\n  /** How much one step changes the value. */\n  step?: number\n  /** Printed after the value, such as \"ms\". */\n  unit?: string\n  className?: string\n}\n\nexport function ScrubInput({\n  label,\n  value,\n  defaultValue = 0,\n  onValueChange,\n  min = -Infinity,\n  max = Infinity,\n  step = 1,\n  unit,\n  className,\n}: ScrubInputProps) {\n  const [internal, setInternal] = React.useState(defaultValue)\n  const [draft, setDraft] = React.useState<string | null>(null)\n  const drag = React.useRef<{ x: number; start: number; moved: boolean } | null>(null)\n  const control = React.useRef<HTMLDivElement>(null)\n  const returnFocus = React.useRef(false)\n  const current = value ?? internal\n  const decimals = decimalsOf(step)\n\n  const commit = (next: number) => {\n    const snapped = snapValue(next, min, max, step)\n    setInternal(snapped)\n    if (snapped !== current) onValueChange?.(snapped)\n  }\n\n  const finishTyping = (save: boolean, keepFocus: boolean) => {\n    const typed = Number(draft)\n    if (save && draft?.trim() && Number.isFinite(typed)) commit(typed)\n    returnFocus.current = keepFocus\n    setDraft(null)\n  }\n\n  // The control only exists again after the render that closes the field.\n  React.useEffect(() => {\n    if (draft === null && returnFocus.current) control.current?.focus()\n    returnFocus.current = false\n  }, [draft])\n\n  const onKeyDown = (event: React.KeyboardEvent) => {\n    const moves: Record<string, number> = {\n      ArrowUp: current + step,\n      ArrowRight: current + step,\n      ArrowDown: current - step,\n      ArrowLeft: current - step,\n      PageUp: current + step * LARGE_STEP,\n      PageDown: current - step * LARGE_STEP,\n    }\n    if (event.key === \"Home\" && Number.isFinite(min)) moves.Home = min\n    if (event.key === \"End\" && Number.isFinite(max)) moves.End = max\n    if (event.key === \"Enter\") {\n      event.preventDefault()\n      setDraft(current.toFixed(decimals))\n      return\n    }\n    const next = moves[event.key]\n    if (next === undefined) return\n    event.preventDefault()\n    commit(next)\n  }\n\n  return (\n    <div className={cn(\"inline-flex flex-col gap-0.5 font-(family-name:--f-body)\", className)}>\n      <span className=\"spotcheck-label\">{label}</span>\n      {draft === null ? (\n        <div\n          ref={control}\n          role=\"spinbutton\"\n          tabIndex={0}\n          aria-label={label}\n          aria-valuenow={current}\n          aria-valuemin={Number.isFinite(min) ? min : undefined}\n          aria-valuemax={Number.isFinite(max) ? max : undefined}\n          aria-valuetext={unit ? `${current.toFixed(decimals)} ${unit}` : undefined}\n          className=\"spotcheck-focus flex cursor-ew-resize touch-pan-y items-baseline gap-1.5 select-none\"\n          onKeyDown={onKeyDown}\n          onPointerDown={(event) => {\n            event.currentTarget.setPointerCapture(event.pointerId)\n            drag.current = { x: event.clientX, start: current, moved: false }\n          }}\n          onPointerMove={(event) => {\n            if (!drag.current) return\n            const steps = Math.round((event.clientX - drag.current.x) / PIXELS_PER_STEP)\n            if (steps !== 0) drag.current.moved = true\n            if (drag.current.moved) commit(drag.current.start + steps * step * (event.shiftKey ? LARGE_STEP : 1))\n          }}\n          onPointerUp={() => {\n            // A press with no drag opens the field for typing.\n            if (drag.current && !drag.current.moved) setDraft(current.toFixed(decimals))\n            drag.current = null\n          }}\n          onPointerCancel={() => (drag.current = null)}\n        >\n          <RollNumber\n            text={current.toFixed(decimals)}\n            duration={350}\n            className=\"border-ink border-b-(length:--rule) border-dashed text-[44px]\"\n          />\n          {unit && <span className=\"text-mute font-(family-name:--f-mono) text-xs\">{unit}</span>}\n        </div>\n      ) : (\n        <input\n          autoFocus\n          inputMode=\"decimal\"\n          aria-label={label}\n          value={draft}\n          onChange={(event) => setDraft(event.target.value)}\n          onFocus={(event) => event.target.select()}\n          onBlur={() => finishTyping(true, false)}\n          onKeyDown={(event) => {\n            if (event.key === \"Enter\") finishTyping(true, true)\n            if (event.key === \"Escape\") finishTyping(false, true)\n          }}\n          className=\"spotcheck-display border-accent-ink w-[6ch] border-b-(length:--rule) bg-transparent text-[44px] leading-none tabular-nums outline-none\"\n        />\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:component"
}