{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scrub-input",
  "title": "Scrub Input",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Número que se arrasta na horizontal, como nos editores gráficos. Teclado continua funcionando.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/scrub-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Campo numérico que se arrasta — o controle de painel de edição.\n *\n * Resolve o aperto que slider e input não resolvem juntos: slider gasta largura\n * e perde precisão; input exige clique, seleção e teclado para cada ajuste. Numa\n * sidebar com 12 parâmetros, arrastar o rótulo lateralmente dá ajuste contínuo\n * em zero pixel extra, e duplo-clique ainda dá o valor exato.\n *\n * Quatro entradas para o mesmo valor, todas esperadas por quem vem de\n * After Effects / Figma / Blender:\n *   arrastar · roda do mouse · setas ↑↓ · duplo-clique para digitar\n * `shift` em qualquer uma delas = passo fino (0.1×).\n *\n * Sensibilidade = `(max-min)/300`: a faixa inteira cabe em ~300px de arrasto,\n * então o gesto tem o mesmo \"peso\" para 0–1 e para 0–5000.\n *\n * Pointer events (não mouse events) porque o mesmo painel roda em tablet — e\n * `touch-pan-y` nos alvos de arrasto: de lado é do número, em pé continua rolando a página.\n *\n * Listeners vão no `document`, não no elemento: o ponteiro sai do campo em\n * qualquer arrasto real. Por isso o cleanup no unmount existe — sem ele um\n * unmount no meio do arrasto deixa o cursor `ew-resize` preso na página inteira.\n *\n * ── O que faltava (o \"simples demais\") ──────────────────────────────────────\n * Duas coisas, e nenhuma delas era enfeite.\n *\n * 1. **Não havia evidência.** O campo mostrava o número e escondia a régua: não\n *    dava para saber, olhando, se 64 estava perto do teto ou do chão. Num painel\n *    com doze parâmetros isso vira doze perguntas sem resposta. Agora um fio no\n *    pé do campo mostra a posição dentro de `min…max` — é a mesma anatomia dos\n *    outros medidores da casa (rótulo · valor · qualificador · evidência).\n *\n * 2. **Rótulo e valor gastavam duas linhas** para uma informação de uma linha.\n *    `layout=\"row\"` põe os dois lado a lado e o campo encolhe quase pela metade,\n *    que é o que permite montar a coluna dupla de um painel de edição de\n *    verdade. O padrão continua `stack` porque quem já usa o item conta com a\n *    altura atual.\n *\n * `kind` é o vocabulário do painel de design: porcentagem, ângulo, pixel e\n * múltiplo só mudam o sufixo e o passo padrão — é a mesma peça, não uma peça\n * nova. Passar `suffix` ou `step` na mão continua vencendo o `kind`.\n */\n\n/** Sufixo e passo que cada tipo de campo assume quando não recebe os seus. */\nconst KINDS = {\n  number: { suffix: \"\", step: 1 },\n  percent: { suffix: \"%\", step: 1 },\n  angle: { suffix: \"°\", step: 1 },\n  px: { suffix: \"px\", step: 1 },\n  multiple: { suffix: \"×\", step: 0.1 },\n} as const;\n\nexport type ScrubKind = keyof typeof KINDS;\n\nexport interface ScrubInputProps {\n  label: string;\n  value: number;\n  min: number;\n  max: number;\n  step?: number;\n  suffix?: string;\n  icon?: React.ReactNode;\n  /** Vira `title` — bom lugar para explicar o parâmetro. */\n  hint?: string;\n  /** Vocabulário do painel de design. Só define sufixo e passo padrão. */\n  kind?: ScrubKind;\n  /** \"row\" = rótulo e valor na mesma linha (campo metade da altura). */\n  layout?: \"stack\" | \"row\";\n  /** Botões − e + em volta do valor, pra contagem de um em um e pro dedo. */\n  stepper?: boolean;\n  disabled?: boolean;\n  onChange: (value: number) => void;\n  className?: string;\n}\n\nexport const ScrubInput = React.memo<ScrubInputProps>(function ScrubInput({\n  label,\n  value,\n  min,\n  max,\n  step: stepProp,\n  suffix: suffixProp,\n  icon,\n  hint,\n  kind = \"number\",\n  layout = \"stack\",\n  stepper = false,\n  disabled = false,\n  onChange,\n  className,\n}) {\n  const step = stepProp ?? KINDS[kind].step;\n  const suffix = suffixProp ?? KINDS[kind].suffix;\n\n  const [editing, setEditing] = React.useState(false);\n  const [scrubbing, setScrubbing] = React.useState(false);\n  const [draft, setDraft] = React.useState(\"\");\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const scrub = React.useRef<{ startX: number; startValue: number } | null>(null);\n  const listeners = React.useRef<{\n    onMove: (e: PointerEvent) => void;\n    onUp: () => void;\n  } | null>(null);\n\n  const clamp = React.useCallback(\n    (v: number) => {\n      const c = Math.max(min, Math.min(max, v));\n      // toFixed(10) mata o lixo de ponto flutuante do arredondamento por step.\n      return parseFloat((Math.round(c / step) * step).toFixed(10));\n    },\n    [min, max, step],\n  );\n\n  const decimals = React.useMemo(() => {\n    const s = String(step);\n    const dot = s.indexOf(\".\");\n    return dot === -1 ? 0 : s.length - dot - 1;\n  }, [step]);\n\n  const display = `${value.toFixed(decimals)}${suffix}`;\n\n  const startEdit = React.useCallback(() => {\n    setDraft(String(value));\n    setEditing(true);\n    requestAnimationFrame(() => inputRef.current?.select());\n  }, [value]);\n\n  const commitEdit = React.useCallback(() => {\n    setEditing(false);\n    const parsed = parseFloat(draft);\n    if (!Number.isNaN(parsed)) onChange(clamp(parsed));\n  }, [draft, onChange, clamp]);\n\n  const onScrubDown = React.useCallback(\n    (e: React.PointerEvent) => {\n      if (editing || disabled) return;\n      if (e.pointerType === \"mouse\" && e.button !== 0) return;\n      e.preventDefault();\n      setScrubbing(true);\n      scrub.current = { startX: e.clientX, startValue: value };\n      const inicio = { x: e.clientX, y: e.clientY };\n      // No dedo o número espera o eixo: quem só está rolando a página não muda o valor.\n      let decidido = e.pointerType === \"mouse\";\n      const sensitivity = (max - min) / 300;\n\n      const onMove = (me: PointerEvent) => {\n        if (!scrub.current) return;\n        if (!decidido) {\n          const lado = Math.abs(me.clientX - inicio.x);\n          const pe = Math.abs(me.clientY - inicio.y);\n          if (Math.max(lado, pe) < 6) return;\n          if (pe >= lado) {\n            onUp();\n            return;\n          }\n          decidido = true;\n          // Conta a partir daqui: os pixels da decisão não pulam o valor.\n          scrub.current.startX = me.clientX;\n        }\n        const dx = me.clientX - scrub.current.startX;\n        onChange(\n          clamp(scrub.current.startValue + dx * sensitivity * (me.shiftKey ? 0.1 : 1)),\n        );\n      };\n      const onUp = () => {\n        scrub.current = null;\n        setScrubbing(false);\n        document.removeEventListener(\"pointermove\", onMove);\n        document.removeEventListener(\"pointerup\", onUp);\n        document.removeEventListener(\"pointercancel\", onUp);\n        document.body.style.cursor = \"\";\n        document.body.style.userSelect = \"\";\n        listeners.current = null;\n      };\n\n      listeners.current = { onMove, onUp };\n      document.addEventListener(\"pointermove\", onMove);\n      document.addEventListener(\"pointerup\", onUp);\n      document.addEventListener(\"pointercancel\", onUp);\n      document.body.style.cursor = \"ew-resize\";\n      document.body.style.userSelect = \"none\";\n    },\n    [editing, disabled, value, min, max, onChange, clamp],\n  );\n\n  React.useEffect(\n    () => () => {\n      if (listeners.current) {\n        document.removeEventListener(\"pointermove\", listeners.current.onMove);\n        document.removeEventListener(\"pointerup\", listeners.current.onUp);\n        document.removeEventListener(\"pointercancel\", listeners.current.onUp);\n      }\n      document.body.style.cursor = \"\";\n      document.body.style.userSelect = \"\";\n    },\n    [],\n  );\n\n  const onWheel = React.useCallback(\n    (e: React.WheelEvent) => {\n      e.stopPropagation();\n      const dir = e.deltaY < 0 ? 1 : -1;\n      onChange(clamp(value + dir * step * (e.shiftKey ? 0.1 : 1)));\n    },\n    [value, step, onChange, clamp],\n  );\n\n  const onKeyDown = React.useCallback(\n    (e: React.KeyboardEvent) => {\n      if (editing) {\n        if (e.key === \"Enter\") commitEdit();\n        if (e.key === \"Escape\") {\n          // Quem cancela a edição não fecha também o que está em volta.\n          e.preventDefault();\n          e.stopPropagation();\n          setEditing(false);\n        }\n        return;\n      }\n      if (disabled) return;\n      const mult = e.shiftKey ? 0.1 : 1;\n      // Setas ←→ também: no painel o gesto é horizontal, então quem tabula até\n      // o campo tenta a seta que combina com o arrasto antes de tentar ↑↓.\n      if (e.key === \"ArrowUp\" || e.key === \"ArrowRight\") {\n        e.preventDefault();\n        onChange(clamp(value + step * mult));\n      }\n      if (e.key === \"ArrowDown\" || e.key === \"ArrowLeft\") {\n        e.preventDefault();\n        onChange(clamp(value - step * mult));\n      }\n      // Home/End: as pontas em uma tecla. O `role=\"spinbutton\"` promete isso.\n      if (e.key === \"Home\") {\n        e.preventDefault();\n        onChange(clamp(min));\n      }\n      if (e.key === \"End\") {\n        e.preventDefault();\n        onChange(clamp(max));\n      }\n    },\n    [editing, disabled, value, step, min, max, onChange, clamp, commitEdit],\n  );\n\n  // Posição do valor dentro da faixa: a evidência que faltava.\n  const ratio = max === min ? 0 : (Math.min(max, Math.max(min, value)) - min) / (max - min);\n\n  return (\n    <div\n      data-scrubbing={scrubbing || undefined}\n      className={cn(\n        // Respiro: 12 px de lado e 8 de cima e baixo. Com menos, o rótulo\n        // encostava na borda e a coluna de parâmetros lia como planilha.\n        \"group relative isolate overflow-hidden rounded-[var(--r-control)] border border-border bg-card px-3 py-2 pointer-coarse:min-h-11 pointer-coarse:justify-center\",\n        \"transition-colors [transition-duration:var(--dur-fast)] ease-[var(--ease-out)]\",\n        \"hover:border-ring/40 focus-within:border-ring focus-visible:outline-none focus-visible:[box-shadow:var(--focus)]\",\n        // Repouso · hover · foco · arrastando · desabilitado, os cinco\n        // distinguíveis: enquanto arrasta a borda vira a cor da marca, que é o\n        // único estado que o cursor sozinho não conta (o ponteiro já saiu do\n        // campo faz tempo).\n        \"data-[scrubbing]:border-[var(--brand)]\",\n        disabled && \"pointer-events-none opacity-50\",\n        !editing && \"cursor-ew-resize touch-pan-y select-none\",\n        layout === \"row\"\n          ? \"flex items-center justify-between gap-3\"\n          : \"flex flex-col gap-1.5\",\n        className,\n      )}\n      onWheel={disabled ? undefined : onWheel}\n      onKeyDown={onKeyDown}\n      // O campo inteiro arrasta: o valor e o rótulo são alvos pequenos demais pro dedo.\n      onPointerDown={(e) => {\n        // No mouse o botão de passo é só botão. No dedo o arrasto vale nele também: a área\n        // de toque dele toma a lateral do campo, e o toque sem arrasto segue sendo clique.\n        const alvo = e.target as Element;\n        if (editing || disabled || alvo.closest(\"input\")) return;\n        if (e.pointerType === \"mouse\" && alvo.closest(\"button\")) return;\n        onScrubDown(e);\n      }}\n      // tabIndex sai do container enquanto edita: o foco pertence ao input.\n      tabIndex={editing || disabled ? -1 : 0}\n      role=\"spinbutton\"\n      aria-label={label}\n      aria-valuenow={value}\n      aria-valuemin={min}\n      aria-valuemax={max}\n      aria-valuetext={display}\n      aria-disabled={disabled || undefined}\n      title={hint}\n    >\n      <span\n        className={cn(\n          \"truncate font-mono text-[10px] uppercase leading-none tracking-widest text-muted-foreground\",\n          layout === \"row\" && \"min-w-0 flex-1\",\n        )}\n      >\n        {icon && <span className=\"mr-1 inline-flex align-middle opacity-70\">{icon}</span>}\n        {label}\n      </span>\n      <div\n        className={cn(\n          \"flex min-w-0 items-center\",\n          layout === \"row\" && \"shrink-0\",\n          stepper && \"justify-between gap-2\",\n        )}\n      >\n        {stepper && !editing ? (\n          <Passo rotulo={`Diminuir ${label}`} sinal=\"−\" onClick={() => onChange(clamp(value - step))} />\n        ) : null}\n        {editing ? (\n          <input\n            ref={inputRef}\n            type=\"text\"\n            value={draft}\n            onChange={(e) => setDraft(e.target.value)}\n            onBlur={commitEdit}\n            onKeyDown={onKeyDown}\n            className={cn(\n              \"w-full bg-transparent font-mono text-[12px] tabular-nums text-foreground outline-none\",\n              layout === \"row\" && \"w-16 text-right\",\n            )}\n            autoFocus\n          />\n        ) : (\n          <span\n            className=\"font-mono text-[12px] leading-none tabular-nums text-foreground\"\n            onDoubleClick={startEdit}\n          >\n            {display}\n          </span>\n        )}\n        {stepper && !editing ? (\n          <Passo rotulo={`Aumentar ${label}`} sinal=\"+\" onClick={() => onChange(clamp(value + step))} />\n        ) : null}\n      </div>\n\n      {/*\n       * A régua. Fio no pé do campo, só `transform` (nada de largura animada) e\n       * SEM transição: o valor está sendo produzido pela mão da pessoa, então\n       * ele segue o dedo. Transição aqui seria latência com boa aparência.\n       *\n       * Só aparece quando precisa: no hover, no foco e no arrasto. Em repouso,\n       * doze fios coloridos numa coluna eram doze riscos disputando o olho com\n       * os números.\n       */}\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 h-px origin-left bg-[var(--brand)] opacity-0 transition-opacity [transition-duration:var(--dur-fast)] ease-[var(--ease-out)] group-hover:opacity-100 group-focus-within:opacity-100 group-data-[scrubbing]:opacity-100\"\n        style={{ transform: `scaleX(${ratio})` }}\n      />\n    </div>\n  );\n});\n\n/** Fora da ordem de Tab: no teclado as setas do campo já fazem o mesmo. */\nfunction Passo({ rotulo, sinal, onClick }: { rotulo: string; sinal: string; onClick: () => void }) {\n  return (\n    <button\n      type=\"button\"\n      tabIndex={-1}\n      aria-label={rotulo}\n      onClick={onClick}\n      className=\"relative grid size-5 shrink-0 place-items-center rounded-[var(--r-control)] font-mono text-[13px] leading-none text-muted-foreground outline-none transition-colors [transition-duration:var(--dur-fast)] ease-[var(--ease-out)] hover:bg-muted hover:text-foreground pointer-coarse:size-6 pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5\"\n    >\n      {sinal}\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Campo numérico arrastável em editor ou ferramenta de design, como tamanho, opacidade ou ângulo",
      "evite": "Valor numa faixa visível pro público geral: elastic-slider ou slosh-slider"
    }
  },
  "categories": [
    "controles",
    "numero",
    "gesto"
  ],
  "type": "registry:ui"
}