{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "ScrubField-JS-TW",
	"title": "ScrubField",
	"description": "Number chip you drag to scrub: the value follows the hand, pushes past the range on a rubber band, and a click without moving opens it for typing.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "ScrubField/ScrubField.jsx",
			"content": "'use client';\n\nimport { useEffect, useId, useRef, useState } from 'react';\nimport {\n  animate,\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform\n} from 'motion/react';\n\nconst SPRING_UI = { type: 'spring', duration: 0.3, bounce: 0 };\nconst LEAN = 4;\nconst SIZES = {\n  sm: { height: 28, font: 12, radius: 6, width: 104 },\n  md: { height: 34, font: 13, radius: 8, width: 128 },\n  lg: { height: 44, font: 16, radius: 10, width: 160 }\n};\n\nconst clamp = (value, min, max) => Math.min(max, Math.max(min, value));\nconst decimalsOf = n => {\n  const s = String(n);\n  const i = s.indexOf('.');\n  return i < 0 ? 0 : s.length - i - 1;\n};\nconst onColor = hex => {\n  const raw = hex.replace('#', '');\n  const full = raw.length === 3 ? [...raw].map(ch => ch + ch).join('') : raw.slice(0, 6);\n  const n = parseInt(full, 16);\n  if (Number.isNaN(n)) return '#ffffff';\n  const yiq = (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000;\n  return yiq >= 128 ? '#111111' : '#ffffff';\n};\n\nexport default function ScrubField({\n  label = 'Radius',\n  suffix = 'px',\n  value: valueProp,\n  defaultValue = 24,\n  min = 0,\n  max = 100,\n  step = 1,\n  size = 'md',\n  sensitivity = 2,\n  rubberReach = 8,\n  returnDuration = 300,\n  coarseMultiplier = 10,\n  fineMultiplier = 0.1,\n  showDelta = true,\n  showDirty = false,\n  showFill = true,\n  accent = '#f5f5f5',\n  chipColor = '#27272a',\n  disabled = false,\n  onChange,\n  onCommit,\n  className = ''\n}) {\n  const id = useId();\n  const reduce = useReducedMotion();\n  const controlled = valueProp !== undefined;\n  const [value, setValue] = useState(controlled ? valueProp : defaultValue);\n  const [dragging, setDragging] = useState(false);\n  const [draft, setDraft] = useState(null);\n  const display = useMotionValue(value);\n  const chipRef = useRef(null);\n  const inputRef = useRef(null);\n  const ghostRef = useRef(null);\n  const drag = useRef(null);\n  const valueRef = useRef(value);\n  const typingRef = useRef(false);\n  const movedRef = useRef(false);\n  const endRef = useRef(() => {});\n  const escRef = useRef(null);\n  const mounted = useRef(false);\n\n  const baseDecimals = decimalsOf(step);\n  const fineDecimals = Math.min(6, baseDecimals + decimalsOf(fineMultiplier));\n  const preset = SIZES[size] || SIZES.md;\n\n  const fmt = v => {\n    const scaled = v * 10 ** baseDecimals;\n    return v.toFixed(Math.abs(scaled - Math.round(scaled)) < 1e-6 ? baseDecimals : fineDecimals);\n  };\n  const signed = d => (d < 0 ? '−' : '+') + fmt(Math.abs(d));\n\n  const reach = (rubberReach / 100) * Math.max(max - min, Number.EPSILON);\n  const bend = raw => (reach ? Math.sign(raw) * reach * Math.log1p(Math.abs(raw) / reach) : 0);\n  const unbend = over => (reach ? Math.sign(over) * reach * Math.expm1(Math.abs(over) / reach) : 0);\n  const toShown = raw => {\n    const c = clamp(raw, min, max);\n    return c + bend(raw - c);\n  };\n  const toRaw = shown => {\n    const c = clamp(shown, min, max);\n    return c + unbend(shown - c);\n  };\n  const multiplierOf = e => (e.shiftKey ? coarseMultiplier : e.altKey ? fineMultiplier : 1);\n\n  const commit = next => {\n    const rounded = clamp(Number(next.toFixed(fineDecimals)), min, max);\n    if (rounded === valueRef.current) return;\n    valueRef.current = rounded;\n    setValue(rounded);\n    onChange?.(rounded);\n  };\n\n  useMotionValueEvent(display, 'change', d => {\n    if (!typingRef.current && inputRef.current) inputRef.current.value = fmt(d);\n    if (chipRef.current) chipRef.current.dataset.over = d < min || d > max ? 'true' : 'false';\n  });\n  const lean = useTransform(display, d =>\n    reduce || !reach ? 0 : clamp((d - clamp(d, min, max)) / reach, -1, 1) * LEAN\n  );\n  const chipTransform = useMotionTemplate`translateX(${lean}px)`;\n  const fill = useTransform(display, d => (clamp(d, min, max) - min) / Math.max(max - min, Number.EPSILON));\n  const fillTransform = useMotionTemplate`scaleX(${fill})`;\n\n  const adopt = next => {\n    valueRef.current = next;\n    setValue(next);\n    display.jump(next);\n    if (!typingRef.current && inputRef.current) inputRef.current.value = fmt(next);\n  };\n\n  useEffect(() => {\n    if (controlled && !drag.current && valueProp !== valueRef.current) adopt(clamp(valueProp, min, max));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [valueProp]);\n  useEffect(() => {\n    if (!mounted.current) {\n      mounted.current = true;\n      return;\n    }\n    if (!controlled && !drag.current) adopt(clamp(defaultValue, min, max));\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [defaultValue]);\n  useEffect(() => {\n    if (!typingRef.current && inputRef.current) inputRef.current.value = fmt(valueRef.current);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [baseDecimals, fineDecimals]);\n\n  const handlePointerDown = e => {\n    if (disabled || drag.current || e.button !== 0 || typingRef.current) return;\n    if (e.pointerType !== 'touch') e.preventDefault();\n    display.stop();\n    movedRef.current = false;\n    drag.current = {\n      id: e.pointerId,\n      x: e.clientX,\n      raw: toRaw(display.get()),\n      mult: multiplierOf(e),\n      moved: false,\n      before: valueRef.current,\n      slack: e.pointerType === 'touch' ? 8 : 3,\n      left: chipRef.current ? chipRef.current.getBoundingClientRect().left : 0\n    };\n    try {\n      e.currentTarget.setPointerCapture(e.pointerId);\n    } catch {}\n    escRef.current = ev => {\n      if (ev.key === 'Escape') endRef.current(true);\n    };\n    window.addEventListener('keydown', escRef.current);\n  };\n\n  const handlePointerMove = e => {\n    const g = drag.current;\n    if (!g || e.pointerId !== g.id) return;\n    if (!g.moved) {\n      if (Math.abs(e.clientX - g.x) < g.slack) return;\n      g.moved = true;\n      movedRef.current = true;\n      g.x = e.clientX;\n      setDragging(true);\n      document.documentElement.style.cursor = 'ew-resize';\n    }\n    const m = multiplierOf(e);\n    if (m !== g.mult) {\n      g.mult = m;\n      g.raw = toRaw(display.get());\n      g.x = e.clientX;\n    }\n    const raw = g.raw + Math.round((e.clientX - g.x) / sensitivity) * step * m;\n    const shown = toShown(raw);\n    display.set(shown);\n    commit(clamp(raw, min, max));\n    if (ghostRef.current) {\n      ghostRef.current.style.translate = `calc(${e.clientX - g.left}px - 50%) -100%`;\n      ghostRef.current.textContent = signed(shown - g.before);\n    }\n  };\n\n  const end = (cancel = false) => {\n    const g = drag.current;\n    if (!g) return;\n    drag.current = null;\n    setDragging(false);\n    document.documentElement.style.cursor = '';\n    if (escRef.current) {\n      window.removeEventListener('keydown', escRef.current);\n      escRef.current = null;\n    }\n    if (cancel) {\n      commit(g.before);\n      display.jump(g.before);\n      return;\n    }\n    if (!g.moved) {\n      inputRef.current?.focus();\n      return;\n    }\n    const bound = clamp(display.get(), min, max);\n    if (display.get() !== bound) {\n      if (reduce) display.jump(bound);\n      else animate(display, bound, { ...SPRING_UI, duration: returnDuration / 1000 });\n    }\n    if (g.moved && valueRef.current !== g.before) onCommit?.(valueRef.current);\n  };\n  endRef.current = end;\n\n  const leaveTyping = () => {\n    typingRef.current = false;\n    setDraft(null);\n    if (inputRef.current) inputRef.current.value = fmt(valueRef.current);\n  };\n\n  const handleKeyDown = e => {\n    const typedNumber = draft !== null && draft.trim() !== '' ? Number(draft) : NaN;\n    const from = Number.isNaN(typedNumber) ? valueRef.current : typedNumber;\n    const deltas = {\n      ArrowUp: step * multiplierOf(e),\n      ArrowDown: -step * multiplierOf(e),\n      PageUp: step * coarseMultiplier,\n      PageDown: -step * coarseMultiplier\n    };\n    const delta = deltas[e.key];\n    if (delta !== undefined || e.key === 'Home' || e.key === 'End') {\n      e.preventDefault();\n      leaveTyping();\n      commit(delta !== undefined ? from + delta : e.key === 'Home' ? min : max);\n      display.jump(valueRef.current);\n      if (inputRef.current) inputRef.current.value = fmt(valueRef.current);\n      onCommit?.(valueRef.current);\n    } else if (e.key === 'Enter') {\n      e.preventDefault();\n      e.currentTarget.blur();\n    } else if (e.key === 'Escape') {\n      e.preventDefault();\n      leaveTyping();\n      e.currentTarget.blur();\n    }\n  };\n\n  const handleFocus = e => {\n    typingRef.current = true;\n    setDraft(e.target.value);\n    e.target.select();\n  };\n\n  const handleBlur = () => {\n    const n = draft === null ? NaN : parseFloat(draft.replace(/[^\\d.-]/g, ''));\n    if (!Number.isNaN(n)) {\n      commit(n);\n      onCommit?.(valueRef.current);\n    }\n    leaveTyping();\n    display.jump(valueRef.current);\n  };\n\n  useEffect(\n    () => () => {\n      document.documentElement.style.cursor = '';\n      if (escRef.current) window.removeEventListener('keydown', escRef.current);\n    },\n    []\n  );\n\n  const dirty = showDirty && value !== defaultValue;\n\n  return (\n    <motion.div\n      ref={chipRef}\n      className={`group relative inline-flex h-[var(--sf-h)] w-[var(--sf-w)] items-center gap-1 rounded-[var(--sf-r)] pl-1 pr-2 leading-none select-none isolate [font-family:inherit] [font-size:var(--sf-fs)] [background:var(--sf-chip)] [box-shadow:0_0_0_1px_transparent] cursor-ew-resize touch-pan-y data-[typing=true]:cursor-text data-[disabled=true]:cursor-default [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] [transition:background-color_200ms_ease,box-shadow_200ms_ease] data-[dirty=true]:[box-shadow:0_0_0_1px_color-mix(in_srgb,var(--sf-accent)_55%,transparent)] data-[dirty=true]:[transition-duration:0ms] data-[typing=true]:[background:color-mix(in_srgb,currentColor_7%,var(--sf-chip))] data-[disabled=true]:opacity-50${className ? ` ${className}` : ''}`}\n      data-dirty={dirty ? 'true' : 'false'}\n      data-dragging={dragging ? 'true' : 'false'}\n      data-typing={draft !== null ? 'true' : 'false'}\n      data-disabled={disabled ? 'true' : 'false'}\n      aria-disabled={disabled || undefined}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={() => end()}\n      onPointerCancel={() => end(true)}\n      onLostPointerCapture={() => end()}\n      style={{\n        '--sf-accent': accent,\n        '--sf-chip': chipColor,\n        '--sf-ghost-ink': onColor(accent),\n        '--sf-h': `${preset.height}px`,\n        '--sf-fs': `${preset.font}px`,\n        '--sf-r': `${preset.radius}px`,\n        '--sf-w': `${preset.width}px`,\n        '--sf-ease-out': 'cubic-bezier(0.23, 1, 0.32, 1)',\n        transform: chipTransform\n      }}\n    >\n      {showFill ? (\n        <span\n          className=\"pointer-events-none absolute inset-0 -z-10 overflow-hidden rounded-[inherit]\"\n          aria-hidden=\"true\"\n        >\n          <motion.span\n            className=\"absolute inset-0 origin-left [background:color-mix(in_srgb,var(--sf-accent)_16%,transparent)]\"\n            style={{ transform: fillTransform }}\n          />\n        </span>\n      ) : null}\n      <label\n        htmlFor={id}\n        className=\"inline-flex h-full cursor-[inherit] items-center whitespace-nowrap rounded-[calc(var(--sf-r)-2px)] px-1.5 font-medium [color:color-mix(in_srgb,currentColor_55%,transparent)] [transition:color_120ms_ease,transform_160ms_var(--sf-ease-out)] [@media(hover:hover)_and_(pointer:fine)]:group-data-[disabled=false]:group-hover:[color:color-mix(in_srgb,currentColor_85%,transparent)] group-data-[disabled=false]:group-data-[typing=false]:group-active:[transform:scale(0.96)] group-data-[disabled=false]:group-data-[typing=false]:group-active:[color:currentColor] group-data-[dragging=true]:[transform:scale(0.96)] group-data-[dragging=true]:[color:currentColor] motion-reduce:[transition:color_120ms_ease] motion-reduce:group-data-[dragging=true]:[transform:none] motion-reduce:group-data-[disabled=false]:group-data-[typing=false]:group-active:[transform:none]\"\n        onClick={e => {\n          if (movedRef.current) e.preventDefault();\n        }}\n      >\n        {label}\n      </label>\n      <input\n        ref={inputRef}\n        id={id}\n        className=\"m-0 w-full min-w-0 flex-1 cursor-[inherit] border-0 bg-transparent p-0 text-right font-medium tabular-nums outline-0 [color:inherit] [font-family:inherit] [font-size:inherit] [transition:color_120ms_ease] group-data-[typing=true]:cursor-text group-data-[over=true]:[color:color-mix(in_srgb,currentColor_55%,transparent)]\"\n        type=\"text\"\n        inputMode=\"decimal\"\n        role=\"spinbutton\"\n        aria-valuenow={value}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuetext={`${fmt(value)}${suffix ? ` ${suffix}` : ''}`}\n        defaultValue={fmt(value)}\n        disabled={disabled}\n        onFocus={handleFocus}\n        onChange={e => setDraft(e.target.value)}\n        onKeyDown={handleKeyDown}\n        onBlur={handleBlur}\n      />\n      {suffix ? (\n        <span className=\"font-medium [color:color-mix(in_srgb,currentColor_45%,transparent)]\" aria-hidden=\"true\">\n          {suffix}\n        </span>\n      ) : null}\n      {showDelta ? (\n        <span\n          ref={ghostRef}\n          className=\"pointer-events-none absolute -top-1.5 left-0 origin-bottom whitespace-nowrap rounded-full px-1.5 py-0.5 text-[11px] leading-[1.4] font-semibold tabular-nums opacity-0 [color:var(--sf-ghost-ink)] [translate:0_-100%] [scale:0.95] [background:var(--sf-accent)] [transition:opacity_125ms_var(--sf-ease-out),scale_125ms_var(--sf-ease-out)] group-data-[dragging=true]:opacity-100 group-data-[dragging=true]:[scale:1] motion-reduce:[scale:1] motion-reduce:[transition:opacity_200ms_ease]\"\n          aria-hidden=\"true\"\n        />\n      ) : null}\n    </motion.div>\n  );\n}\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"motion@^12.23.12"
	]
}