{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scrub-number-field",
  "title": "Scrub Number Field",
  "author": "jay15git",
  "description": "A Figma-like scrub number input with drag-to-scrub, click-to-edit, digit animation by Calligraph, Up/Down arrow nudging, and wheel nudging.",
  "dependencies": [
    "calligraph",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "input",
    "input-group"
  ],
  "files": [
    {
      "path": "components/ui/scrub-number-input.tsx",
      "content": "\"use client\"\n\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type ComponentProps,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent,\n  type RefObject,\n} from \"react\"\nimport { Calligraph } from \"calligraph\"\nimport {\n  GripHorizontal,\n  GripVertical,\n  Move,\n  MoveHorizontal,\n  MoveVertical,\n  Percent,\n  type LucideIcon,\n} from \"lucide-react\"\nimport { useReducedMotion, motion } from \"motion/react\"\n\nimport { InputGroup, InputGroupAddon } from \"@/components/ui/input-group\"\nimport { Input } from \"@/components/ui/input\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { useDisplayOverflowTruncated } from \"@/lib/scrub-number-overflow\"\nimport {\n  boundOverflow,\n  clampNumber,\n  countDraftDecimalPlaces,\n  formatDisplayValue,\n  getAtBound,\n  getBoundEdge,\n  getScrubPointerDelta,\n  hasExceededScrubThreshold,\n  isFineModifierPressed,\n  applyStepDelta,\n  consumeWheelDelta,\n  normalizeCoarseModifier,\n  normalizeFineModifier,\n  normalizeFiniteNumber,\n  normalizeNumberFieldBounds,\n  normalizePositiveFiniteStep,\n  normalizeScrubThreshold,\n  normalizeWheelDelta,\n  normalizeWheelSensitivity,\n  quantizeNumber,\n  resolveActiveStep,\n  resolveFineStep,\n  resolveQuantizeStep,\n  resolveExclusiveModifiers,\n  resolveScrubStepModifiers,\n  preserveDisplayDraft,\n  resolveDisplayDecimalPlaces,\n  sanitizeNumericDraft,\n  toModifierKeys,\n  type CoarseModifier,\n  type FineModifier,\n} from \"@/lib/scrub-number-math\"\nimport { cn } from \"@/lib/utils\"\nimport { cva } from \"class-variance-authority\"\n\nimport \"./scrub-number-input.css\"\n\nexport {\n  boundOverflow,\n  clampNumber,\n  countDraftDecimalPlaces,\n  formatDisplayValue,\n  formatMinimalDisplayValue,\n  getAtBound,\n  getBoundEdge,\n  getDecimalPlaces,\n  getCoarseModifierLabel,\n  getFineModifierLabel,\n  getScrubPointerDelta,\n  hasExceededScrubThreshold,\n  isCoarseModifierPressed,\n  isFineModifierPressed,\n  isModifierKeyPressed,\n  applyStepDelta,\n  consumeWheelDelta,\n  normalizeCoarseModifier,\n  normalizeFineModifier,\n  normalizeFiniteNumber,\n  normalizeNumberFieldBounds,\n  normalizePositiveFiniteStep,\n  normalizeScrubThreshold,\n  normalizeWheelDelta,\n  normalizeWheelSensitivity,\n  quantizeNumber,\n  resolveActiveStep,\n  resolveCoarseModifierKey,\n  resolveFineModifierKey,\n  resolveFineStep,\n  resolveQuantizeStep,\n  resolveExclusiveModifiers,\n  resolveScrubStepModifiers,\n  preserveDisplayDraft,\n  resolveDisplayDecimalPlaces,\n  toModifierKeys,\n  getValueDecimalPlaces,\n  stepFromDecimalPlaces,\n  MODIFIER_OPTIONS,\n  type CoarseModifier,\n  type FineModifier,\n  type DisplayFormat,\n  type ModifierKey,\n} from \"@/lib/scrub-number-math\"\n\nconst SCRUB_NUMBER_FIELD_CLASS = \"tabular-nums\"\n\nconst SCRUB_NUMBER_SPINNER_HIDE_CLASS =\n  \"[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n\nconst scrubFieldVariants = cva(\n  \"w-full min-w-0 rounded-[12px] border border-input bg-[var(--input-fill)] py-1 text-start text-base text-foreground transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 h-7 px-2 text-[0.8rem]\",\n)\n\nexport type InputSettings = {\n  selectOnEdit: boolean\n}\n\nexport const DEFAULT_INPUT_SETTINGS: InputSettings = {\n  selectOnEdit: true,\n}\n\nexport type FormatSettings = {\n  alwaysShowSign: boolean\n}\n\nexport const DEFAULT_FORMAT_SETTINGS: FormatSettings = {\n  alwaysShowSign: false,\n}\n\nexport type BoundFeedbackMode =\n  | \"none\"\n  | \"shake\"\n  | \"borderPulse\"\n\nexport const BOUND_FEEDBACK_MODES = [\n  \"none\",\n  \"shake\",\n  \"borderPulse\",\n] as const satisfies readonly BoundFeedbackMode[]\n\nexport type BoundFeedbackSource = \"wheel\" | \"key\" | \"scrub\"\n\nexport type BoundFeedbackState = {\n  edge: \"min\" | \"max\"\n  overflow: number\n  source: BoundFeedbackSource\n  tick: number\n}\n\nexport type ScrubSettings = {\n  direction: \"horizontal\" | \"vertical\"\n  shiftStep: number\n  sensitivity: number\n  threshold?: number\n  wheelEnabled: boolean\n  boundFeedback: BoundFeedbackMode\n  fineStep?: number\n  fineModifier?: FineModifier\n  coarseModifier?: CoarseModifier\n  wheelSensitivity?: number\n}\n\nexport const DEFAULT_SCRUB_SETTINGS: ScrubSettings = {\n  direction: \"horizontal\",\n  shiftStep: 10,\n  sensitivity: 1,\n  threshold: 3,\n  wheelEnabled: false,\n  boundFeedback: \"none\",\n  fineModifier: \"alt\",\n  coarseModifier: \"shift\",\n  wheelSensitivity: 20,\n}\n\nexport type CalligraphSettings = {\n  variant: \"number\" | \"slots\"\n  animation: \"default\" | \"smooth\" | \"snappy\" | \"bouncy\"\n  stagger: number\n  autoSize: boolean\n}\n\nexport const DEFAULT_CALLIGRAPH_SETTINGS: CalligraphSettings = {\n  variant: \"slots\",\n  animation: \"snappy\",\n  stagger: 0.02,\n  autoSize: false,\n}\n\nconst VALUE_NUDGE_KEYS = new Set([\n  \"ArrowUp\",\n  \"ArrowDown\",\n  \"PageUp\",\n  \"PageDown\",\n])\n\nexport const LOGO_ICON_OPTIONS = [\n  \"GripVertical\",\n  \"GripHorizontal\",\n  \"Move\",\n  \"MoveHorizontal\",\n  \"MoveVertical\",\n  \"Percent\",\n] as const\n\nexport type LogoIconName = (typeof LOGO_ICON_OPTIONS)[number]\n\nexport type LogoSettings = {\n  enabled: boolean\n  icon: LogoIconName\n}\n\nexport const DEFAULT_LOGO_SETTINGS: LogoSettings = {\n  enabled: false,\n  icon: \"GripVertical\",\n}\n\nconst LOGO_ICONS: Record<LogoIconName, LucideIcon> = {\n  GripVertical,\n  GripHorizontal,\n  Move,\n  MoveHorizontal,\n  MoveVertical,\n  Percent,\n}\n\nexport function ScrubLogoIcon({\n  className,\n  name,\n}: {\n  className?: string\n  name: LogoIconName\n}) {\n  const Icon = LOGO_ICONS[name]\n  return <Icon aria-hidden className={className} />\n}\n\nexport type ScrubFieldSettings = {\n  calligraph: CalligraphSettings\n  input: InputSettings\n  logo: LogoSettings\n  min?: number\n  max?: number\n  step?: number\n  smallStep?: number\n  largeStep?: number\n  direction?: \"horizontal\" | \"vertical\"\n  pixelSensitivity?: number\n  allowWheelScrub?: boolean\n  boundFeedback?: BoundFeedbackMode\n  format?: Intl.NumberFormatOptions\n}\n\nexport const DEFAULT_SCRUB_FIELD_SETTINGS: ScrubFieldSettings = {\n  calligraph: DEFAULT_CALLIGRAPH_SETTINGS,\n  input: DEFAULT_INPUT_SETTINGS,\n  logo: DEFAULT_LOGO_SETTINGS,\n  step: 1,\n  smallStep: 0.1,\n  largeStep: 10,\n  direction: \"horizontal\",\n  pixelSensitivity: 2,\n  allowWheelScrub: false,\n  boundFeedback: \"none\",\n}\n\nfunction flatSettingsToScrubSettings(\n  settings: Pick<\n    ScrubFieldSettings,\n    | \"smallStep\"\n    | \"largeStep\"\n    | \"direction\"\n    | \"pixelSensitivity\"\n    | \"allowWheelScrub\"\n    | \"boundFeedback\"\n    | \"step\"\n  >,\n): ScrubSettings {\n  const step = normalizePositiveFiniteStep(settings.step)\n  let fineStep = settings.smallStep ?? 0.1\n  if (!Number.isFinite(fineStep) || fineStep <= 0 || fineStep >= step) {\n    fineStep = resolveFineStep(step)\n  }\n\n  let shiftStep = settings.largeStep ?? DEFAULT_SCRUB_SETTINGS.shiftStep\n  if (!Number.isFinite(shiftStep) || shiftStep < step) {\n    shiftStep = Math.max(step, DEFAULT_SCRUB_SETTINGS.shiftStep)\n  }\n\n  const sensitivity =\n    typeof settings.pixelSensitivity === \"number\" &&\n    Number.isFinite(settings.pixelSensitivity) &&\n    settings.pixelSensitivity > 0\n      ? settings.pixelSensitivity\n      : DEFAULT_SCRUB_SETTINGS.sensitivity\n\n  return {\n    ...DEFAULT_SCRUB_SETTINGS,\n    direction: settings.direction === \"vertical\" ? \"vertical\" : \"horizontal\",\n    boundFeedback: settings.boundFeedback ?? \"none\",\n    wheelEnabled: Boolean(settings.allowWheelScrub),\n    sensitivity,\n    fineStep,\n    shiftStep,\n  }\n}\n\nexport function normalizeScrubFieldSettings(\n  settings: ScrubFieldSettings,\n): ScrubFieldSettings {\n  let min = settings.min\n  let max = settings.max\n\n  if (\n    min != null &&\n    max != null &&\n    Number.isFinite(min) &&\n    Number.isFinite(max) &&\n    min > max\n  ) {\n    ;[min, max] = [max, min]\n  }\n\n  const step =\n    typeof settings.step === \"number\" && Number.isFinite(settings.step) && settings.step > 0\n      ? settings.step\n      : 1\n\n  let smallStep = settings.smallStep ?? 0.1\n  if (!Number.isFinite(smallStep) || smallStep <= 0 || smallStep >= step) {\n    smallStep = Math.min(step / 10, step) || 0.1\n  }\n\n  let largeStep = settings.largeStep ?? 10\n  if (!Number.isFinite(largeStep) || largeStep < step) {\n    largeStep = Math.max(step, 10)\n  }\n\n  const requestedLogoIcon = settings.logo?.icon\n  const logoIcon =\n    requestedLogoIcon &&\n    LOGO_ICON_OPTIONS.includes(requestedLogoIcon as LogoIconName)\n      ? requestedLogoIcon\n      : DEFAULT_LOGO_SETTINGS.icon\n\n  const pixelSensitivity =\n    typeof settings.pixelSensitivity === \"number\" &&\n    Number.isFinite(settings.pixelSensitivity) &&\n    settings.pixelSensitivity > 0\n      ? settings.pixelSensitivity\n      : 2\n\n  return {\n    calligraph: { ...DEFAULT_CALLIGRAPH_SETTINGS, ...settings.calligraph },\n    input: { ...DEFAULT_INPUT_SETTINGS, ...settings.input },\n    logo: {\n      ...DEFAULT_LOGO_SETTINGS,\n      ...settings.logo,\n      icon: logoIcon,\n    },\n    min,\n    max,\n    step,\n    smallStep,\n    largeStep,\n    direction: settings.direction === \"vertical\" ? \"vertical\" : \"horizontal\",\n    pixelSensitivity,\n    allowWheelScrub: Boolean(settings.allowWheelScrub),\n    boundFeedback: settings.boundFeedback ?? \"none\",\n    format: settings.format,\n  }\n}\n\nexport function getScrubCursorClass(\n  scrub: Pick<ScrubSettings, \"direction\">,\n  atBound: \"min\" | \"max\" | null = null,\n  bounds?: { min?: number; max?: number },\n) {\n  if (\n    bounds?.min != null &&\n    bounds?.max != null &&\n    bounds.min === bounds.max\n  ) {\n    return \"cursor-not-allowed\"\n  }\n\n  if (scrub.direction === \"vertical\") {\n    if (atBound === \"min\") {\n      return \"cursor-n-resize\"\n    }\n\n    if (atBound === \"max\") {\n      return \"cursor-s-resize\"\n    }\n\n    return \"cursor-ns-resize\"\n  }\n\n  if (atBound === \"min\") {\n    return \"cursor-e-resize\"\n  }\n\n  if (atBound === \"max\") {\n    return \"cursor-w-resize\"\n  }\n\n  return \"cursor-ew-resize\"\n}\n\ntype EditPointerPoint = {\n  clientX: number\n  clientY: number\n}\n\nfunction approximateCaretFromX(input: HTMLInputElement, clientX: number) {\n  const text = input.value\n  const rect = input.getBoundingClientRect()\n  const style = getComputedStyle(input)\n  const paddingLeft = Number.parseFloat(style.paddingLeft) || 0\n  const paddingRight = Number.parseFloat(style.paddingRight) || 0\n  const contentWidth = rect.width - paddingLeft - paddingRight\n\n  const canvas = document.createElement(\"canvas\")\n  const ctx = canvas.getContext(\"2d\")\n\n  if (!ctx) {\n    input.setSelectionRange(text.length, text.length)\n    scrollCaretIntoView(input)\n    return\n  }\n\n  ctx.font = `${style.fontStyle} ${style.fontVariant} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`\n  ctx.letterSpacing = style.letterSpacing\n\n  const textWidth = ctx.measureText(text).width\n  let textStart = paddingLeft + input.scrollLeft\n\n  const textAlign = style.textAlign\n\n  if (textAlign === \"center\") {\n    textStart += Math.max(0, (contentWidth - textWidth) / 2)\n  } else if (textAlign === \"right\" || textAlign === \"end\") {\n    textStart += Math.max(0, contentWidth - textWidth)\n  }\n\n  const relativeX = clientX - rect.left - textStart\n\n  if (relativeX <= 0) {\n    input.setSelectionRange(0, 0)\n    scrollCaretIntoView(input)\n    return\n  }\n\n  if (relativeX >= textWidth) {\n    input.setSelectionRange(text.length, text.length)\n    scrollCaretIntoView(input)\n    return\n  }\n\n  let offset = 0\n\n  for (let index = 1; index <= text.length; index++) {\n    const width = ctx.measureText(text.slice(0, index)).width\n\n    if (width >= relativeX) {\n      const previousWidth =\n        index > 1 ? ctx.measureText(text.slice(0, index - 1)).width : 0\n      const characterWidth = ctx.measureText(text[index - 1] ?? \"\").width\n      offset = relativeX - previousWidth < characterWidth / 2 ? index - 1 : index\n      break\n    }\n\n    offset = index\n  }\n\n  input.setSelectionRange(offset, offset)\n  scrollCaretIntoView(input)\n}\n\nfunction placeCaretAtPoint(\n  input: HTMLInputElement,\n  clientX: number,\n  clientY: number,\n) {\n  const doc = input.ownerDocument\n\n  if (typeof doc.caretRangeFromPoint === \"function\") {\n    const range = doc.caretRangeFromPoint(clientX, clientY)\n\n    if (range && input.contains(range.startContainer)) {\n      const offset = Math.min(range.startOffset, input.value.length)\n      input.setSelectionRange(offset, offset)\n      scrollCaretIntoView(input)\n      return true\n    }\n  }\n\n  if (typeof doc.caretPositionFromPoint === \"function\") {\n    const position = doc.caretPositionFromPoint(clientX, clientY)\n\n    if (\n      position &&\n      (input === position.offsetNode || input.contains(position.offsetNode))\n    ) {\n      const offset = Math.min(position.offset, input.value.length)\n      input.setSelectionRange(offset, offset)\n      scrollCaretIntoView(input)\n      return true\n    }\n  }\n\n  approximateCaretFromX(input, clientX)\n  return true\n}\n\nfunction inputTextOverflows(input: HTMLInputElement) {\n  return input.scrollWidth > input.clientWidth + 1\n}\n\nfunction scrollCaretIntoView(input: HTMLInputElement) {\n  if (!inputTextOverflows(input)) {\n    return\n  }\n\n  const caret = input.selectionStart ?? input.value.length\n  const style = getComputedStyle(input)\n  const paddingLeft = Number.parseFloat(style.paddingLeft) || 0\n  const paddingRight = Number.parseFloat(style.paddingRight) || 0\n  const contentWidth = input.clientWidth - paddingLeft - paddingRight\n  const canvas = document.createElement(\"canvas\")\n  const ctx = canvas.getContext(\"2d\")\n\n  if (!ctx || contentWidth <= 0) {\n    return\n  }\n\n  ctx.font = `${style.fontStyle} ${style.fontVariant} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`\n  ctx.letterSpacing = style.letterSpacing\n\n  const textBeforeCaret = input.value.slice(0, caret)\n  const caretX = ctx.measureText(textBeforeCaret).width\n  const maxScroll = Math.max(0, input.scrollWidth - input.clientWidth)\n\n  if (caretX - input.scrollLeft < paddingLeft) {\n    input.scrollLeft = Math.max(0, caretX - paddingLeft)\n    return\n  }\n\n  const visibleEnd = input.scrollLeft + contentWidth - paddingRight\n\n  if (caretX > visibleEnd) {\n    input.scrollLeft = Math.min(maxScroll, caretX - contentWidth + paddingRight)\n  }\n}\n\nfunction focusCaretAtEnd(input: HTMLInputElement) {\n  const length = input.value.length\n  input.setSelectionRange(length, length)\n  scrollCaretIntoView(input)\n}\n\nfunction focusInputForEdit(\n  input: HTMLInputElement,\n  selectOnEdit: boolean,\n  point?: EditPointerPoint,\n) {\n  input.focus({ preventScroll: true })\n\n  if (selectOnEdit) {\n    input.select()\n    scrollCaretIntoView(input)\n    return\n  }\n\n  if (point) {\n    placeCaretAtPoint(input, point.clientX, point.clientY)\n    return\n  }\n\n  focusCaretAtEnd(input)\n}\n\nconst SCRUB_BOUND_FEEDBACK_MS = 80 * 2 + 60 * 2\n\nconst SCRUB_BOUND_REVERT_HOLD_MS = 600\n\nfunction restartBoundShake(targets: HTMLElement[]) {\n  for (const element of targets) {\n    element.classList.remove(\"is-shaking\")\n    void element.offsetWidth\n    element.classList.add(\"is-shaking\")\n  }\n}\n\nfunction clearBoundShake(targets: HTMLElement[]) {\n  for (const element of targets) {\n    element.classList.remove(\"is-shaking\")\n  }\n}\n\nfunction ScrubBoundFeedback({\n  boundFeedback,\n  children,\n  className,\n  mode,\n  onFeedbackComplete,\n}: {\n  boundFeedback: BoundFeedbackState | null\n  children: React.ReactNode\n  className?: string\n  mode: BoundFeedbackMode\n  onFeedbackComplete: () => void\n}) {\n  const shouldReduceMotion = useReducedMotion()\n  const wrapRef = useRef<HTMLDivElement>(null)\n  const [boundHit, setBoundHit] = useState<\"min\" | \"max\" | null>(null)\n  const [boundError, setBoundError] = useState(false)\n\n  useEffect(() => {\n    if (!boundFeedback || mode === \"none\") {\n      setBoundError(false)\n      return\n    }\n\n    const shakeTargets = wrapRef.current\n      ? Array.from(\n          wrapRef.current.querySelectorAll<HTMLElement>(\".scrub-bound-field\"),\n        )\n      : []\n\n    if (mode === \"shake\") {\n      setBoundError(true)\n\n      if (!shouldReduceMotion && shakeTargets.length > 0) {\n        restartBoundShake(shakeTargets)\n      }\n\n      const completeTimer = window.setTimeout(() => {\n        onFeedbackComplete()\n      }, shouldReduceMotion ? 0 : SCRUB_BOUND_FEEDBACK_MS)\n\n      const revertTimer = window.setTimeout(() => {\n        setBoundError(false)\n        clearBoundShake(shakeTargets)\n      }, shouldReduceMotion\n        ? 0\n        : SCRUB_BOUND_FEEDBACK_MS + SCRUB_BOUND_REVERT_HOLD_MS)\n\n      return () => {\n        window.clearTimeout(completeTimer)\n        window.clearTimeout(revertTimer)\n      }\n    }\n\n    if (mode === \"borderPulse\") {\n      setBoundHit(boundFeedback.edge)\n      const timeout = window.setTimeout(() => {\n        setBoundHit(null)\n        onFeedbackComplete()\n      }, shouldReduceMotion ? 0 : SCRUB_BOUND_FEEDBACK_MS)\n\n      return () => {\n        window.clearTimeout(timeout)\n      }\n    }\n  }, [\n    boundFeedback,\n    mode,\n    onFeedbackComplete,\n    shouldReduceMotion,\n  ])\n\n  return (\n    <div className={className}>\n      <div\n        ref={wrapRef}\n        data-slot=\"scrub-bound-feedback\"\n        data-bound-hit={boundHit ?? undefined}\n        className={cn(\n          \"scrub-bound-wrap\",\n          boundError && \"is-bound-error\",\n        )}\n      >\n        {children}\n      </div>\n    </div>\n  )\n}\n\nexport type UseNumberScrubOptions = {\n  disabled?: boolean\n  format?: FormatSettings\n  formatValue?: (value: number) => string\n  logo?: LogoSettings\n  max?: number\n  min?: number\n  onChange: (value: number) => void\n  onValueCommit?: (value: number) => void\n  defaultResetValue?: number\n  scrub?: ScrubSettings\n  selectOnEdit?: boolean\n  shiftStep?: number\n  step?: number\n  value: number\n}\n\nexport type ScrubState = ReturnType<typeof useNumberScrub>\n\nexport function useNumberScrub({\n  disabled = false,\n  format = DEFAULT_FORMAT_SETTINGS,\n  formatValue,\n  logo = DEFAULT_LOGO_SETTINGS,\n  max: maxProp,\n  min: minProp,\n  onChange,\n  onValueCommit,\n  defaultResetValue,\n  scrub = DEFAULT_SCRUB_SETTINGS,\n  selectOnEdit = true,\n  shiftStep: shiftStepProp,\n  step: stepProp = 1,\n  value: valueProp,\n}: UseNumberScrubOptions) {\n  const { min, max } = normalizeNumberFieldBounds(minProp, maxProp)\n  const value = normalizeFiniteNumber(valueProp) ?? 0\n  const step = normalizePositiveFiniteStep(stepProp)\n  const effectiveShiftStep = normalizePositiveFiniteStep(\n    shiftStepProp ?? scrub.shiftStep,\n    Math.max(step, DEFAULT_SCRUB_SETTINGS.shiftStep),\n  )\n  const fineStep = resolveFineStep(step, scrub.fineStep)\n  const wheelSensitivity = normalizeWheelSensitivity(scrub.wheelSensitivity)\n  const fineModifier = normalizeFineModifier(\n    scrub.fineModifier,\n    DEFAULT_SCRUB_SETTINGS.fineModifier ?? \"alt\",\n  )\n  const coarseModifier = normalizeCoarseModifier(\n    scrub.coarseModifier,\n    DEFAULT_SCRUB_SETTINGS.coarseModifier ?? \"shift\",\n  )\n  const scrubThreshold = normalizeScrubThreshold(scrub.threshold)\n  const logoScrollEnabled = logo.enabled\n  const userDecimalPlacesRef = useRef<number | null>(null)\n  const displayDecimalPlacesRef = useRef<number | null>(null)\n  const wheelDeltaRef = useRef(0)\n  const wheelModifierRef = useRef<string | null>(null)\n  const formatForEdit = useCallback(\n    (nextValue: number) =>\n      formatDisplayValue(\n        nextValue,\n        format,\n        userDecimalPlacesRef.current ?? displayDecimalPlacesRef.current,\n      ),\n    [format],\n  )\n  const formatForDisplay = useCallback(\n    (nextValue: number) => {\n      if (formatValue) {\n        return formatValue(nextValue)\n      }\n\n      return formatForEdit(nextValue)\n    },\n    [formatForEdit, formatValue],\n  )\n  const [draft, setDraft] = useState(() => formatForEdit(value))\n  const draftRef = useRef(draft)\n  draftRef.current = draft\n  const [editing, setEditing] = useState(false)\n  const editingRef = useRef(false)\n  editingRef.current = editing\n  const [invalid, setInvalid] = useState(false)\n  const [boundFeedback, setBoundFeedback] = useState<BoundFeedbackState | null>(\n    null,\n  )\n  const boundFeedbackRef = useRef<BoundFeedbackState | null>(boundFeedback)\n  boundFeedbackRef.current = boundFeedback\n  const boundFeedbackTickRef = useRef(0)\n  const boundFeedbackLatchedRef = useRef({ max: false, min: false })\n  const interactingRef = useRef(false)\n  const lastCommittedValueRef = useRef(value)\n  const lastNudgeDirectionRef = useRef<1 | -1 | 0>(0)\n  const [interactionEpoch, setInteractionEpoch] = useState(0)\n  const lastClickRef = useRef<{\n    time: number\n    x: number\n    y: number\n  } | null>(null)\n  const inputRef = useRef<HTMLInputElement>(null)\n  const displaySurfaceRef = useRef<HTMLDivElement>(null)\n  const surfaceRef = useRef<HTMLDivElement>(null)\n  const scrubRef = useRef<{\n    captureTarget: HTMLElement | null\n    pointerId: number\n    scrubbing: boolean\n    source: \"input\" | \"label\"\n    startValue: number\n    startX: number\n    startY: number\n  } | null>(null)\n  const scrubSessionGuardRef = useRef<(() => void) | null>(null)\n  const pendingEditKeyboardNudgeRef = useRef<{\n    direction: 1 | -1\n    modifiers: { coarse?: boolean; fine?: boolean }\n    source: BoundFeedbackSource\n  } | null>(null)\n\n  const detachScrubSessionGuard = useCallback(() => {\n    scrubSessionGuardRef.current?.()\n    scrubSessionGuardRef.current = null\n  }, [])\n\n  const notifyCommit = useCallback(\n    (committedValue: number) => {\n      onValueCommit?.(committedValue)\n    },\n    [onValueCommit],\n  )\n\n  const resetToDefault = useCallback(() => {\n    if (defaultResetValue == null) {\n      return false\n    }\n\n    const bounded = clampNumber(defaultResetValue, min, max)\n    onChange(bounded)\n    lastCommittedValueRef.current = bounded\n    setDraft(formatForEdit(bounded))\n    notifyCommit(bounded)\n    return true\n  }, [defaultResetValue, formatForEdit, max, min, notifyCommit, onChange])\n\n  const finishInteraction = useCallback(() => {\n    if (!interactingRef.current) {\n      return\n    }\n\n    interactingRef.current = false\n    setInteractionEpoch((epoch) => epoch + 1)\n  }, [])\n\n  useEffect(() => {\n    if (interactingRef.current) {\n      return\n    }\n\n    const parsedDraft = Number(draftRef.current.replace(/^\\+/, \"\"))\n    const isExternalChange =\n      Number.isFinite(parsedDraft) &&\n      parsedDraft !== value &&\n      lastCommittedValueRef.current !== value\n\n    if (isExternalChange) {\n      userDecimalPlacesRef.current = null\n      displayDecimalPlacesRef.current = null\n    }\n\n    const nextDraft = preserveDisplayDraft(\n      draftRef.current,\n      value,\n      formatForEdit(value),\n    )\n    displayDecimalPlacesRef.current = resolveDisplayDecimalPlaces(\n      nextDraft,\n      userDecimalPlacesRef.current,\n    )\n    draftRef.current = nextDraft\n    setDraft(nextDraft)\n    lastCommittedValueRef.current = value\n  }, [formatForEdit, value])\n\n  useEffect(() => {\n    if (min != null && value > min) {\n      boundFeedbackLatchedRef.current.min = false\n    }\n\n    if (max != null && value < max) {\n      boundFeedbackLatchedRef.current.max = false\n    }\n  }, [max, min, value])\n\n  const clearBoundFeedback = useCallback(() => {\n    if (boundFeedbackRef.current === null) {\n      return\n    }\n\n    setBoundFeedback(null)\n  }, [])\n\n  const triggerBoundFeedback = useCallback(\n    (\n      edge: \"min\" | \"max\",\n      source: BoundFeedbackSource,\n      attempted: number,\n    ) => {\n      if (scrub.boundFeedback === \"none\") {\n        return\n      }\n\n      if (boundFeedbackLatchedRef.current[edge]) {\n        return\n      }\n\n      boundFeedbackLatchedRef.current[edge] = true\n\n      boundFeedbackTickRef.current += 1\n      setBoundFeedback({\n        edge,\n        overflow: boundOverflow(attempted, edge, min, max),\n        source,\n        tick: boundFeedbackTickRef.current,\n      })\n    },\n    [max, min, scrub.boundFeedback],\n  )\n\n  const getCurrentNumericValue = useCallback(() => {\n    const current = Number(draftRef.current.replace(/^\\+/, \"\"))\n    return Number.isFinite(current) ? current : value\n  }, [value])\n\n  const resolveCommitQuantizeStep = useCallback(\n    (currentValue: number, fine = false) =>\n      resolveQuantizeStep({\n        step,\n        fineStep,\n        fine,\n        currentValue,\n        userDecimalPlaces: userDecimalPlacesRef.current,\n      }),\n    [fineStep, step],\n  )\n\n  const commit = useCallback(\n    (\n      nextValue: number,\n      source?: BoundFeedbackSource,\n      quantizeStep = step,\n      activeStep = step,\n      direction?: 1 | -1,\n    ) => {\n      const current = getCurrentNumericValue()\n      const attempted = quantizeNumber(nextValue, quantizeStep)\n      const bounded = clampNumber(attempted, min, max)\n\n      if (source) {\n        const edge = getBoundEdge(current, attempted, min, max)\n\n        if (edge) {\n          triggerBoundFeedback(edge, source, attempted)\n        } else if (source === \"scrub\" && boundFeedbackRef.current !== null) {\n          setBoundFeedback(null)\n        }\n      }\n\n      if (direction) {\n        lastNudgeDirectionRef.current = direction\n      } else if (bounded !== current) {\n        lastNudgeDirectionRef.current = bounded > current ? 1 : -1\n      }\n\n      onChange(bounded)\n      const decimalPlaces = resolveDisplayDecimalPlaces(\n        draftRef.current,\n        userDecimalPlacesRef.current,\n        activeStep,\n      )\n      displayDecimalPlacesRef.current = decimalPlaces\n      const nextDraft = formatForEdit(bounded)\n      draftRef.current = nextDraft\n      setDraft(nextDraft)\n      lastCommittedValueRef.current = bounded\n\n      return bounded\n    },\n    [\n      formatForEdit,\n      getCurrentNumericValue,\n      max,\n      min,\n      onChange,\n      step,\n      triggerBoundFeedback,\n    ],\n  )\n\n  const getActiveStep = useCallback(\n    (modifiers: { coarse?: boolean; fine?: boolean }) =>\n      resolveActiveStep({\n        step,\n        shiftStep: effectiveShiftStep,\n        fineStep,\n        coarse: modifiers.coarse,\n        fine: modifiers.fine,\n      }),\n    [effectiveShiftStep, fineStep, step],\n  )\n\n  const resetWheelAccumulator = useCallback(() => {\n    wheelDeltaRef.current = 0\n    wheelModifierRef.current = null\n  }, [])\n\n  const getStepModifiers = useCallback(\n    (event: {\n      shiftKey: boolean\n      altKey: boolean\n      metaKey: boolean\n      getModifierState?: (key: string) => boolean\n    }) =>\n      resolveScrubStepModifiers(toModifierKeys(event), {\n        fineModifier,\n        coarseModifier,\n      }),\n    [coarseModifier, fineModifier],\n  )\n\n  const getDomStepModifiers = useCallback(\n    (event: {\n      shiftKey: boolean\n      altKey: boolean\n      metaKey: boolean\n      getModifierState: (key: string) => boolean\n    }) => getStepModifiers(event),\n    [getStepModifiers],\n  )\n\n  const applyDisplayNudge = useCallback(\n    (\n      direction: 1 | -1,\n      modifiers: { coarse?: boolean; fine?: boolean },\n      source: BoundFeedbackSource,\n      count = 1,\n    ) => {\n      if (editingRef.current) {\n        return false\n      }\n\n      const current = getCurrentNumericValue()\n      const fine = modifiers.fine ?? false\n      const activeStep = getActiveStep({\n        coarse: modifiers.coarse ?? false,\n        fine,\n      })\n      const attempted = applyStepDelta(current, direction * activeStep * count, {\n        step,\n        fineStep,\n        fine,\n        userDecimalPlaces: userDecimalPlacesRef.current,\n      })\n      const bounded = clampNumber(attempted, min, max)\n\n      if (bounded === current) {\n        const edge = getBoundEdge(current, attempted, min, max)\n\n        if (edge) {\n          triggerBoundFeedback(edge, source, attempted)\n        }\n\n        return true\n      }\n\n      interactingRef.current = true\n      lastNudgeDirectionRef.current = direction\n      const decimalPlaces = resolveDisplayDecimalPlaces(\n        draftRef.current,\n        userDecimalPlacesRef.current,\n        activeStep,\n      )\n      displayDecimalPlacesRef.current = decimalPlaces\n      const nextDraft = formatForEdit(bounded)\n      draftRef.current = nextDraft\n      setDraft(nextDraft)\n      lastCommittedValueRef.current = bounded\n      onChange(bounded)\n\n      return true\n    },\n    [\n      fineStep,\n      formatForEdit,\n      getActiveStep,\n      getCurrentNumericValue,\n      max,\n      min,\n      onChange,\n      step,\n      triggerBoundFeedback,\n    ],\n  )\n\n  const applyWheelNudge = useCallback(\n    (event: WheelEvent) => {\n      const modifiers = getDomStepModifiers({\n        shiftKey: event.shiftKey,\n        altKey: event.altKey,\n        metaKey: event.metaKey,\n        getModifierState: (key) => event.getModifierState(key as \"Shift\"),\n      })\n      const modifierKey = `${modifiers.coarse}:${modifiers.fine}`\n\n      if (wheelModifierRef.current !== modifierKey) {\n        wheelDeltaRef.current = 0\n        wheelModifierRef.current = modifierKey\n      }\n\n      const normalizedDelta = normalizeWheelDelta(\n        event.deltaY,\n        event.deltaMode,\n      )\n      const { accumulated, direction, steps } = consumeWheelDelta(\n        wheelDeltaRef.current,\n        normalizedDelta,\n        wheelSensitivity,\n      )\n      wheelDeltaRef.current = accumulated\n\n      if (steps === 0 || direction === 0) {\n        event.preventDefault()\n        return\n      }\n\n      if (applyDisplayNudge(direction, modifiers, \"wheel\", steps)) {\n        event.preventDefault()\n      }\n    },\n    [applyDisplayNudge, getDomStepModifiers, wheelSensitivity],\n  )\n\n  useEffect(() => {\n    const node = surfaceRef.current\n\n    if (!node || disabled || logoScrollEnabled) {\n      return\n    }\n\n    const handleWheel = (event: WheelEvent) => {\n      if (!scrub.wheelEnabled) {\n        return\n      }\n\n      applyWheelNudge(event)\n    }\n\n    const handlePointerLeave = () => {\n      resetWheelAccumulator()\n    }\n\n    node.addEventListener(\"wheel\", handleWheel, { passive: false })\n    node.addEventListener(\"pointerleave\", handlePointerLeave)\n\n    return () => {\n      node.removeEventListener(\"wheel\", handleWheel)\n      node.removeEventListener(\"pointerleave\", handlePointerLeave)\n    }\n  }, [\n    applyWheelNudge,\n    disabled,\n    logoScrollEnabled,\n    resetWheelAccumulator,\n    scrub.wheelEnabled,\n  ])\n\n  const jumpToBound = useCallback(\n    (target: number) => {\n      commit(target, \"key\")\n    },\n    [commit],\n  )\n\n  const handleKeyboardNudge = useCallback(\n    (event: KeyboardEvent<HTMLElement>) => {\n      if (disabled) {\n        return false\n      }\n\n      const wasEditing =\n        editingRef.current && VALUE_NUDGE_KEYS.has(event.key)\n\n      if (wasEditing) {\n        editingRef.current = false\n        setEditing(false)\n      }\n\n      const modifiers = getDomStepModifiers({\n        shiftKey: event.shiftKey,\n        altKey: event.altKey,\n        metaKey: event.metaKey,\n        getModifierState: (key) => event.getModifierState(key as \"Shift\"),\n      })\n\n      const scheduleOrApplyNudge = (\n        direction: 1 | -1,\n        nudgeModifiers: { coarse?: boolean; fine?: boolean },\n        source: BoundFeedbackSource,\n      ) => {\n        event.preventDefault()\n\n        if (wasEditing) {\n          pendingEditKeyboardNudgeRef.current = {\n            direction,\n            modifiers: nudgeModifiers,\n            source,\n          }\n          return true\n        }\n\n        return applyDisplayNudge(direction, nudgeModifiers, source)\n      }\n\n      switch (event.key) {\n        case \"ArrowUp\":\n          return scheduleOrApplyNudge(1, modifiers, \"key\")\n\n        case \"ArrowDown\":\n          return scheduleOrApplyNudge(-1, modifiers, \"key\")\n\n        case \"PageUp\":\n          return scheduleOrApplyNudge(1, { coarse: true }, \"key\")\n\n        case \"PageDown\":\n          return scheduleOrApplyNudge(-1, { coarse: true }, \"key\")\n\n        case \"Home\":\n          if (min != null) {\n            event.preventDefault()\n            jumpToBound(min)\n            return true\n          }\n          return false\n\n        case \"End\":\n          if (max != null) {\n            event.preventDefault()\n            jumpToBound(max)\n            return true\n          }\n          return false\n\n        default:\n          return false\n      }\n    },\n    [applyDisplayNudge, disabled, getDomStepModifiers, jumpToBound, max, min],\n  )\n\n  useLayoutEffect(() => {\n    const pending = pendingEditKeyboardNudgeRef.current\n\n    if (editing || !pending) {\n      return\n    }\n\n    pendingEditKeyboardNudgeRef.current = null\n    applyDisplayNudge(pending.direction, pending.modifiers, pending.source)\n  }, [applyDisplayNudge, editing])\n\n  useEffect(() => {\n    const handleKeyUp = (event: globalThis.KeyboardEvent) => {\n      if (VALUE_NUDGE_KEYS.has(event.key)) {\n        requestAnimationFrame(() => {\n          finishInteraction()\n        })\n      }\n    }\n\n    window.addEventListener(\"keyup\", handleKeyUp)\n\n    return () => {\n      window.removeEventListener(\"keyup\", handleKeyUp)\n    }\n  }, [finishInteraction])\n\n  const enterEditMode = useCallback(\n    (point?: EditPointerPoint) => {\n      if (disabled) {\n        return\n      }\n\n      editingRef.current = true\n      setEditing(true)\n      interactingRef.current = true\n      setDraft((current) =>\n        preserveDisplayDraft(current, value, formatForEdit(value)),\n      )\n\n      requestAnimationFrame(() => {\n        const input = inputRef.current\n\n        if (!input) {\n          return\n        }\n\n        requestAnimationFrame(() => {\n          if (!inputRef.current) {\n            return\n          }\n\n          focusInputForEdit(inputRef.current, selectOnEdit, point)\n        })\n      })\n    },\n    [disabled, formatForEdit, selectOnEdit, value],\n  )\n\n  const canScrub = !disabled && !editing\n\n  const endScrubSession = useCallback(\n    (event: PointerEvent<HTMLElement>, allowEditOnClick: boolean) => {\n      const state = scrubRef.current\n\n      if (!state) {\n        return\n      }\n\n      const wasScrubbing = state.scrubbing\n      scrubRef.current = null\n      detachScrubSessionGuard()\n\n      if (state.captureTarget) {\n        try {\n          state.captureTarget.releasePointerCapture(event.pointerId)\n        } catch {\n        }\n      }\n\n      if (wasScrubbing) {\n        finishInteraction()\n\n        if (boundFeedbackRef.current !== null) {\n          setBoundFeedback(null)\n        }\n\n        setDraft((current) => {\n          const nextDraft = preserveDisplayDraft(\n            current,\n            value,\n            formatForEdit(value),\n          )\n          draftRef.current = nextDraft\n          displayDecimalPlacesRef.current = resolveDisplayDecimalPlaces(\n            nextDraft,\n            userDecimalPlacesRef.current,\n          )\n          return nextDraft\n        })\n        notifyCommit(value)\n        event.preventDefault()\n        return\n      }\n\n      if (\n        isFineModifierPressed(\n          toModifierKeys({\n            shiftKey: event.shiftKey,\n            altKey: event.altKey,\n            metaKey: event.metaKey,\n            getModifierState: (key) => event.getModifierState(key as \"Shift\"),\n          }),\n          fineModifier,\n        ) &&\n        resetToDefault()\n      ) {\n        event.preventDefault()\n        return\n      }\n\n      const now = Date.now()\n      const lastClick = lastClickRef.current\n\n      if (\n        lastClick &&\n        now - lastClick.time < 300 &&\n        Math.hypot(event.clientX - lastClick.x, event.clientY - lastClick.y) < 5\n      ) {\n        lastClickRef.current = null\n\n        if (state.source === \"label\" && resetToDefault()) {\n          event.preventDefault()\n          return\n        }\n      } else {\n        lastClickRef.current = {\n          time: now,\n          x: event.clientX,\n          y: event.clientY,\n        }\n      }\n\n      if (allowEditOnClick && state.source === \"input\") {\n        enterEditMode({\n          clientX: event.clientX,\n          clientY: event.clientY,\n        })\n      }\n    },\n    [detachScrubSessionGuard, enterEditMode, fineModifier, finishInteraction, formatForEdit, notifyCommit, resetToDefault, value],\n  )\n\n  const attachScrubSessionGuard = useCallback(() => {\n    detachScrubSessionGuard()\n\n    const handleGlobalPointerEnd = (event: globalThis.PointerEvent) => {\n      const state = scrubRef.current\n\n      if (!state || event.pointerId !== state.pointerId) {\n        return\n      }\n\n      endScrubSession(\n        event as unknown as PointerEvent<HTMLElement>,\n        state.source === \"input\",\n      )\n    }\n\n    document.addEventListener(\"pointerup\", handleGlobalPointerEnd)\n    document.addEventListener(\"pointercancel\", handleGlobalPointerEnd)\n\n    scrubSessionGuardRef.current = () => {\n      document.removeEventListener(\"pointerup\", handleGlobalPointerEnd)\n      document.removeEventListener(\"pointercancel\", handleGlobalPointerEnd)\n    }\n  }, [detachScrubSessionGuard, endScrubSession])\n\n  const beginPointerCapture = useCallback(\n    (captureTarget: HTMLElement | null, pointerId: number) => {\n      const state = scrubRef.current\n\n      if (!state || !captureTarget) {\n        return\n      }\n\n      state.captureTarget = captureTarget\n      captureTarget.blur()\n\n      try {\n        captureTarget.setPointerCapture(pointerId)\n      } catch {\n      }\n    },\n    [],\n  )\n\n  const activateScrubbing = useCallback(\n    (\n      event: PointerEvent<HTMLElement>,\n      captureTarget: HTMLElement | null,\n    ) => {\n      const state = scrubRef.current\n\n      if (!state || state.scrubbing) {\n        return\n      }\n\n      state.scrubbing = true\n      interactingRef.current = true\n      event.preventDefault()\n      beginPointerCapture(captureTarget, event.pointerId)\n    },\n    [beginPointerCapture],\n  )\n\n  const applyScrubDelta = useCallback(\n    (event: PointerEvent<HTMLElement>) => {\n      const state = scrubRef.current\n\n      if (!state) {\n        return\n      }\n\n      if (event.pointerType === \"mouse\" && event.buttons === 0) {\n        endScrubSession(event, state.source === \"input\")\n        return\n      }\n\n      if (\n        !state.scrubbing &&\n        hasExceededScrubThreshold(\n          event,\n          state.startX,\n          state.startY,\n          scrub.direction,\n          scrubThreshold,\n        )\n      ) {\n        activateScrubbing(event, event.currentTarget)\n      }\n\n      if (!state.scrubbing) {\n        return\n      }\n\n      const pointerDelta = getScrubPointerDelta(\n        event,\n        state.startX,\n        state.startY,\n        scrub.direction,\n      )\n\n      const modifiers = getDomStepModifiers({\n        shiftKey: event.shiftKey,\n        altKey: event.altKey,\n        metaKey: event.metaKey,\n        getModifierState: (key) => event.getModifierState(key as \"Shift\"),\n      })\n      const delta = getActiveStep(modifiers)\n      const effectiveDelta = pointerDelta / scrub.sensitivity\n      const attempted = applyStepDelta(\n        state.startValue,\n        effectiveDelta * delta,\n        {\n          step,\n          fineStep,\n          fine: modifiers.fine,\n          userDecimalPlaces: userDecimalPlacesRef.current,\n        },\n      )\n      const scrubDirection =\n        effectiveDelta === 0 ? undefined : effectiveDelta > 0 ? 1 : -1\n      commit(\n        attempted,\n        \"scrub\",\n        resolveCommitQuantizeStep(state.startValue, modifiers.fine),\n        delta,\n        scrubDirection,\n      )\n    },\n    [\n      activateScrubbing,\n      commit,\n      endScrubSession,\n      fineStep,\n      getActiveStep,\n      getDomStepModifiers,\n      resolveCommitQuantizeStep,\n      scrub.direction,\n      scrub.sensitivity,\n      scrubThreshold,\n      step,\n    ],\n  )\n\n  const beginLabelScrub = useCallback(\n    (event: PointerEvent<HTMLElement>) => {\n      if (!canScrub) {\n        return\n      }\n\n      if (event.pointerType === \"mouse\" && event.button !== 0) {\n        return\n      }\n\n      const current = Number(draft.replace(/^\\+/, \"\"))\n\n      if (!Number.isFinite(current)) {\n        return\n      }\n\n      scrubRef.current = {\n        captureTarget: event.currentTarget,\n        pointerId: event.pointerId,\n        scrubbing: false,\n        source: \"label\",\n        startValue: current,\n        startX: event.clientX,\n        startY: event.clientY,\n      }\n      attachScrubSessionGuard()\n      event.preventDefault()\n      event.currentTarget.setPointerCapture(event.pointerId)\n    },\n    [attachScrubSessionGuard, canScrub, draft],\n  )\n\n  const beginInputScrub = useCallback(\n    (event: PointerEvent<HTMLElement>) => {\n      if (!canScrub) {\n        return\n      }\n\n      if (event.pointerType === \"mouse\" && event.button !== 0) {\n        return\n      }\n\n      const current = Number(draft.replace(/^\\+/, \"\"))\n\n      if (!Number.isFinite(current)) {\n        return\n      }\n\n      scrubRef.current = {\n        captureTarget: null,\n        pointerId: event.pointerId,\n        scrubbing: false,\n        source: \"input\",\n        startValue: current,\n        startX: event.clientX,\n        startY: event.clientY,\n      }\n      attachScrubSessionGuard()\n      event.preventDefault()\n    },\n    [attachScrubSessionGuard, canScrub, draft],\n  )\n\n  const onInputPointerMove = applyScrubDelta\n\n  const scrubSurfaceHandlers = useMemo(\n    () => ({\n      onPointerCancel: (event: PointerEvent<HTMLElement>) => {\n        endScrubSession(event, false)\n      },\n      onPointerDown: beginInputScrub,\n      onPointerMove: onInputPointerMove,\n      onPointerUp: (event: PointerEvent<HTMLElement>) => {\n        endScrubSession(event, true)\n      },\n    }),\n    [beginInputScrub, endScrubSession, onInputPointerMove],\n  )\n\n  const logoScrubHandlers = useMemo(\n    () => ({\n      onPointerCancel: (event: PointerEvent<HTMLElement>) => {\n        endScrubSession(event, false)\n      },\n      onPointerDown: beginLabelScrub,\n      onPointerMove: applyScrubDelta,\n      onPointerUp: (event: PointerEvent<HTMLElement>) => {\n        endScrubSession(event, false)\n      },\n    }),\n    [applyScrubDelta, beginLabelScrub, endScrubSession],\n  )\n\n  const focusDisplaySurface = useCallback(() => {\n    requestAnimationFrame(() => {\n      displaySurfaceRef.current?.focus()\n    })\n  }, [])\n\n  const onDisplayFocus = useCallback(() => {\n    interactingRef.current = true\n  }, [])\n\n  const onDisplayBlur = useCallback(() => {\n    finishInteraction()\n    resetWheelAccumulator()\n  }, [finishInteraction, resetWheelAccumulator])\n\n  const activateEdit = enterEditMode\n\n  const atBound = getAtBound(value, min, max)\n\n  const spinbuttonProps = {\n    \"aria-valuemax\": max,\n    \"aria-valuemin\": min,\n    \"aria-valuenow\": value,\n    role: \"spinbutton\" as const,\n  }\n\n  const inputProps = {\n    ...spinbuttonProps,\n    \"aria-invalid\": invalid || undefined,\n    \"data-slot\": \"scrub-number-scrubbable\",\n    inputMode: \"decimal\" as const,\n    onBlur: () => {\n      interactingRef.current = false\n      editingRef.current = false\n      setEditing(false)\n\n      const currentDraft = draftRef.current\n      const draftBody = currentDraft.replace(/^\\+/, \"\").trim()\n\n      if (draftBody === \"\" || draftBody === \"-\" || draftBody === \"+\" || draftBody === \".\") {\n        setInvalid(true)\n        setDraft(formatForEdit(value))\n        window.setTimeout(() => {\n          setInvalid(false)\n        }, 600)\n        return\n      }\n\n      const parsed = Number(draftBody)\n\n      if (Number.isFinite(parsed)) {\n        setInvalid(false)\n        const decimalPlaces = countDraftDecimalPlaces(currentDraft)\n        userDecimalPlacesRef.current =\n          decimalPlaces > 0 ? decimalPlaces : null\n        const bounded = commit(\n          parsed,\n          undefined,\n          resolveCommitQuantizeStep(parsed),\n        )\n        notifyCommit(bounded)\n        return\n      }\n\n      setInvalid(true)\n      setDraft(formatForEdit(value))\n      window.setTimeout(() => {\n        setInvalid(false)\n      }, 600)\n    },\n    onChange: (event: ChangeEvent<HTMLInputElement>) => {\n      setInvalid(false)\n      const nextValue = sanitizeNumericDraft(\n        event.currentTarget.value,\n        draftRef.current,\n      )\n      draftRef.current = nextValue\n      setDraft(nextValue)\n    },\n    onFocus: () => {\n      interactingRef.current = true\n      setInvalid(false)\n    },\n    onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => {\n      if (event.key === \"Enter\") {\n        event.currentTarget.blur()\n        return\n      }\n\n      if (event.key === \"Escape\") {\n        setInvalid(false)\n        const revertedDraft = formatForEdit(value)\n        draftRef.current = revertedDraft\n        setDraft(revertedDraft)\n        editingRef.current = false\n        setEditing(false)\n        event.currentTarget.blur()\n        return\n      }\n\n      if (handleKeyboardNudge(event)) {\n        editingRef.current = false\n        event.currentTarget.blur()\n        focusDisplaySurface()\n        return\n      }\n    },\n    ref: inputRef,\n    type: \"text\" as const,\n    value: draft,\n  }\n\n  const handleDisplayKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLElement>) => {\n      if (disabled) {\n        return\n      }\n\n      if (handleKeyboardNudge(event)) {\n        return\n      }\n\n      if (event.key === \"Enter\" || event.key === \" \") {\n        event.preventDefault()\n        enterEditMode()\n      }\n    },\n    [disabled, enterEditMode, handleKeyboardNudge],\n  )\n\n  const visibleDisplayValue = (() => {\n    if (editing) {\n      return draft\n    }\n\n    const draftNumeric = Number(draft.replace(/^\\+/, \"\"))\n\n    if (\n      Number.isFinite(draftNumeric) &&\n      draftNumeric === lastCommittedValueRef.current &&\n      lastCommittedValueRef.current !== value\n    ) {\n      return formatForDisplay(lastCommittedValueRef.current)\n    }\n\n    return formatForDisplay(value)\n  })()\n\n  return {\n    activateEdit,\n    atBound,\n    boundFeedback,\n    canScrub,\n    clearBoundFeedback,\n    displaySurfaceRef,\n    displayValue: visibleDisplayValue,\n    editing,\n    handleDisplayKeyDown,\n    inputProps,\n    inputRef,\n    interactingRef,\n    interactionEpoch,\n    invalid,\n    logoScrubHandlers,\n    logoScrollEnabled,\n    nudgeTrend: lastNudgeDirectionRef.current,\n    onDisplayBlur,\n    onDisplayFocus,\n    scrubSurfaceHandlers,\n    spinbuttonProps,\n    surfaceRef,\n  }\n}\n\nfunction splitSignedDisplayValue(value: string) {\n  if (value.startsWith(\"-\")) {\n    return { body: value.slice(1), sign: \"-\" }\n  }\n\n  if (value.startsWith(\"+\")) {\n    return { body: value.slice(1), sign: \"+\" }\n  }\n\n  return { body: value, sign: \"\" }\n}\n\nfunction mirrorInputTypography(source: HTMLElement): CSSProperties {\n  const computed = getComputedStyle(source)\n\n  return {\n    fontFamily: computed.fontFamily,\n    fontFeatureSettings: computed.fontFeatureSettings,\n    fontSize: computed.fontSize,\n    fontStyle: computed.fontStyle,\n    fontVariantNumeric: computed.fontVariantNumeric as CSSProperties[\"fontVariantNumeric\"],\n    fontWeight: computed.fontWeight,\n    letterSpacing: computed.letterSpacing,\n    lineHeight: computed.lineHeight,\n  }\n}\n\nfunction mirrorCalligraphTypography(source: HTMLElement): CSSProperties {\n  return {\n    ...mirrorInputTypography(source),\n    lineHeight: 1,\n  }\n}\n\nfunction CalligraphNumber({\n  contentRef,\n  layoutKey,\n  settings = DEFAULT_CALLIGRAPH_SETTINGS,\n  style,\n  trend = 0,\n  value,\n}: {\n  contentRef?: RefObject<HTMLSpanElement | null>\n  layoutKey?: string\n  settings?: CalligraphSettings\n  style?: CSSProperties\n  trend?: 1 | -1 | 0\n  value: string\n}) {\n  const shouldReduceMotion = useReducedMotion()\n  const { body, sign } = splitSignedDisplayValue(value)\n\n  const animation =\n    settings.animation === \"default\" ? undefined : settings.animation\n\n  if (shouldReduceMotion) {\n    return (\n      <span\n        ref={contentRef}\n        data-slot=\"scrub-number-calligraph-content\"\n        style={style}\n      >\n        {value}\n      </span>\n    )\n  }\n\n  return (\n    <span\n      ref={contentRef}\n      className=\"inline-flex items-center justify-start\"\n      data-slot=\"scrub-number-calligraph-content\"\n      style={style}\n    >\n      {sign ? (\n        <span aria-hidden=\"true\" className=\"inline-block\" style={style}>\n          {sign}\n        </span>\n      ) : null}\n      <Calligraph\n        key={layoutKey}\n        animation={animation}\n        autoSize={settings.autoSize}\n        className=\"scrub-number-calligraph inline-flex items-center justify-start leading-none\"\n        stagger={settings.stagger}\n        style={style}\n        trend={trend}\n        variant={settings.variant}\n      >\n        {body}\n      </Calligraph>\n    </span>\n  )\n}\n\nfunction getFieldClasses(inputClassName?: string, extra?: string) {\n  return cn(\n    scrubFieldVariants(),\n    SCRUB_NUMBER_FIELD_CLASS,\n    SCRUB_NUMBER_SPINNER_HIDE_CLASS,\n    extra,\n    inputClassName,\n  )\n}\n\nexport function ScrubNumberInput({\n  calligraph = DEFAULT_CALLIGRAPH_SETTINGS,\n  className,\n  disabled,\n  grouped = false,\n  inputClassName,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars -- API parity only\n  inputSettings = DEFAULT_INPUT_SETTINGS,\n  logo = DEFAULT_LOGO_SETTINGS,\n  max,\n  min,\n  scrub,\n  scrubSettings = DEFAULT_SCRUB_SETTINGS,\n  ...props\n}: {\n  calligraph?: CalligraphSettings\n  className?: string\n  disabled?: boolean\n  grouped?: boolean\n  inputClassName?: string\n  inputSettings?: InputSettings\n  logo?: LogoSettings\n  max?: number\n  min?: number\n  scrub: ScrubState\n  scrubSettings?: ScrubSettings\n} & Omit<ComponentProps<\"input\">, \"onChange\" | \"type\" | \"value\" | \"size\">) {\n  const scrubBounds = { min, max }\n  const fieldClass = getFieldClasses(inputClassName)\n  const ariaLabel = props[\"aria-label\"]\n  const mirrorRef = useRef<HTMLInputElement>(null)\n  const calligraphClipRef = useRef<HTMLDivElement>(null)\n  const calligraphContentRef = useRef<HTMLSpanElement>(null)\n  const [mirroredTypography, setMirroredTypography] = useState<CSSProperties>({})\n  const prevTypographyRef = useRef<string>(\"\")\n  const logoScrollEnabled = scrub.logoScrollEnabled\n  const usesInputGroup = logoScrollEnabled\n  const usesGroupedControl = grouped || logoScrollEnabled\n  const isDisplayTruncated = useDisplayOverflowTruncated(\n    calligraphClipRef,\n    [scrub.displayValue, mirroredTypography, scrub.editing, scrub.interactionEpoch],\n    mirrorRef,\n  )\n  const displaySpinbuttonProps = {\n    ...scrub.spinbuttonProps,\n    ...(isDisplayTruncated ? { \"aria-valuetext\": scrub.displayValue } : {}),\n  }\n\n  useLayoutEffect(() => {\n    const syncMirroredTypography = () => {\n      if (scrub.interactingRef.current) {\n        return\n      }\n\n      const source = scrub.editing ? scrub.inputRef.current : mirrorRef.current\n\n      if (!source) {\n        return\n      }\n\n      const nextTypography = mirrorCalligraphTypography(source)\n      const nextKey = JSON.stringify(nextTypography)\n      const typographyChanged = nextKey !== prevTypographyRef.current\n\n      prevTypographyRef.current = nextKey\n\n      if (!typographyChanged) {\n        return\n      }\n\n      setMirroredTypography(nextTypography)\n    }\n\n    syncMirroredTypography()\n\n    const source = scrub.editing ? scrub.inputRef.current : mirrorRef.current\n\n    if (!source || typeof ResizeObserver === \"undefined\") {\n      return\n    }\n\n    const observer = new ResizeObserver(syncMirroredTypography)\n    observer.observe(source)\n\n    return () => {\n      observer.disconnect()\n    }\n  }, [scrub.displayValue, scrub.editing, scrub.inputRef, scrub.interactingRef, scrub.interactionEpoch])\n\n  const groupControlClass =\n    \"relative z-[1] flex min-w-0 w-full flex-1 items-center justify-start overflow-hidden rounded-none border-0 bg-transparent text-foreground shadow-none dark:bg-transparent\"\n\n  const calligraphLayoutKey = usesGroupedControl ? \"group\" : \"field\"\n\n  const scrubSurface = scrub.editing ? (\n    <Input\n      {...props}\n      {...scrub.inputProps}\n      className={cn(\n        fieldClass,\n        usesGroupedControl\n          ? \"w-full rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent\"\n          : \"relative z-[1] scrub-bound-field\",\n        \"text-start\",\n      )}\n      disabled={disabled}\n      data-slot={usesGroupedControl ? \"input-group-control\" : undefined}\n    />\n  ) : (\n    <div\n      ref={scrub.displaySurfaceRef}\n      {...(logoScrollEnabled ? {} : scrub.scrubSurfaceHandlers)}\n      {...displaySpinbuttonProps}\n      aria-label={typeof ariaLabel === \"string\" ? ariaLabel : undefined}\n      aria-invalid={scrub.invalid || undefined}\n      className={cn(\n        fieldClass,\n        usesGroupedControl\n          ? groupControlClass\n          : cn(\n              \"relative z-[1] flex items-center justify-start text-foreground scrub-bound-field\",\n            ),\n        !logoScrollEnabled &&\n          scrub.canScrub &&\n          getScrubCursorClass(scrubSettings, scrub.atBound, scrubBounds),\n        !logoScrollEnabled && scrub.canScrub && \"select-none\",\n        logoScrollEnabled && \"cursor-text\",\n        disabled && \"cursor-not-allowed opacity-50\",\n        scrub.invalid && \"is-bound-error\",\n      )}\n      data-slot={usesGroupedControl ? \"input-group-control\" : \"scrub-number-scrubbable\"}\n      tabIndex={disabled ? -1 : 0}\n      title={isDisplayTruncated ? scrub.displayValue : undefined}\n      onClick={\n        logoScrollEnabled && !disabled\n          ? () => {\n              scrub.activateEdit()\n            }\n          : undefined\n      }\n      onBlur={scrub.onDisplayBlur}\n      onFocus={scrub.onDisplayFocus}\n      onKeyDown={scrub.handleDisplayKeyDown}\n    >\n      <motion.div\n        {...(grouped ? {} : { layoutRoot: true })}\n        ref={calligraphClipRef}\n        className={cn(\n          \"pointer-events-none relative flex w-full min-w-0 items-center justify-start overflow-hidden text-foreground\",\n        )}\n        data-slot=\"scrub-number-calligraph-value\"\n        style={mirroredTypography}\n      >\n        <CalligraphNumber\n          contentRef={calligraphContentRef}\n          layoutKey={calligraphLayoutKey}\n          settings={calligraph}\n          style={mirroredTypography}\n          trend={scrub.nudgeTrend}\n          value={scrub.displayValue}\n        />\n      </motion.div>\n    </div>\n  )\n\n  const fieldContent = (\n    <div\n      className={cn(\n        \"relative\",\n        usesGroupedControl ? \"flex min-w-0 flex-1 overflow-hidden\" : \"shrink-0\",\n      )}\n    >\n      <Input\n        ref={mirrorRef}\n        aria-hidden\n        aria-label={\n          typeof ariaLabel === \"string\" ? ariaLabel : \"Scrub number value\"\n        }\n        className={fieldClass}\n        readOnly\n        tabIndex={-1}\n        value={scrub.displayValue}\n        style={{\n          inset: 0,\n          opacity: 0,\n          pointerEvents: \"none\",\n          position: \"absolute\",\n          zIndex: 0,\n        }}\n      />\n      {scrubSurface}\n    </div>\n  )\n\n  const inputGroup = (\n    <InputGroup\n      className={cn(\"h-7 scrub-bound-field w-full\")}\n      data-logo-scroll={logoScrollEnabled ? \"\" : undefined}\n    >\n      {fieldContent}\n      {logoScrollEnabled ? (\n        <InputGroupAddon\n          align=\"inline-end\"\n          aria-label=\"Scrub value with logo\"\n          {...scrub.logoScrubHandlers}\n          className={cn(\n            \"shrink-0 select-none pr-1.5\",\n            scrub.canScrub &&\n              getScrubCursorClass(scrubSettings, scrub.atBound, scrubBounds),\n          )}\n          data-slot=\"scrub-number-logo-scroll\"\n          onClick={(event) => {\n            event.preventDefault()\n          }}\n        >\n          <ScrubLogoIcon\n            className=\"pointer-events-none size-3.5 shrink-0 text-muted-foreground\"\n            name={logo.icon}\n          />\n        </InputGroupAddon>\n      ) : null}\n    </InputGroup>\n  )\n\n  return (\n    <div ref={scrub.surfaceRef} className={cn(\"relative shrink-0\", className)}>\n      <ScrubBoundFeedback\n        boundFeedback={scrub.boundFeedback}\n        className={usesInputGroup || grouped ? \"w-full min-w-0\" : undefined}\n        mode={scrubSettings.boundFeedback}\n        onFeedbackComplete={scrub.clearBoundFeedback}\n      >\n        {usesInputGroup ? inputGroup : fieldContent}\n      </ScrubBoundFeedback>\n    </div>\n  )\n}\n\nexport type ScrubNumberFieldProps = Omit<\n  ComponentProps<\"input\">,\n  \"onChange\" | \"type\" | \"value\" | \"defaultValue\" | \"size\" | \"format\"\n> & {\n  allowWheelScrub?: boolean\n  boundFeedback?: BoundFeedbackMode\n  calligraph?: CalligraphSettings\n  defaultResetValue?: number\n  direction?: \"horizontal\" | \"vertical\"\n  format?: Intl.NumberFormatOptions\n  formatValue?: (value: number) => string\n  grouped?: boolean\n  inputSettings?: InputSettings\n  label?: string\n  labelClassName?: string\n  largeStep?: number\n  logo?: LogoSettings\n  onValueChange?: (value: number) => void\n  onValueCommitted?: (value: number) => void\n  pixelSensitivity?: number\n  smallStep?: number\n  value?: number\n  defaultValue?: number\n  min?: number\n  max?: number\n  step?: number\n  className?: string\n  inputClassName?: string\n}\n\nexport function ScrubNumberField({\n  allowWheelScrub = false,\n  boundFeedback = \"none\",\n  calligraph = DEFAULT_CALLIGRAPH_SETTINGS,\n  className,\n  defaultResetValue,\n  defaultValue,\n  direction = \"horizontal\",\n  disabled,\n  format,\n  formatValue: formatValueProp,\n  grouped = false,\n  inputSettings = DEFAULT_INPUT_SETTINGS,\n  label,\n  labelClassName,\n  largeStep = 10,\n  logo = DEFAULT_LOGO_SETTINGS,\n  max,\n  min,\n  onValueChange,\n  onValueCommitted,\n  pixelSensitivity = 2,\n  smallStep = 0.1,\n  step = 1,\n  value: valueProp,\n  inputClassName,\n  ...props\n}: ScrubNumberFieldProps) {\n  const { min: normalizedMin, max: normalizedMax } = normalizeNumberFieldBounds(\n    min,\n    max,\n  )\n\n  const [value, setValue] = useControllableState({\n    prop: valueProp,\n    defaultProp: defaultValue ?? 0,\n    onChange: onValueChange,\n    caller: \"ScrubNumberField\",\n  })\n\n  const resetValue = defaultResetValue ?? defaultValue\n\n  const scrubSettings = flatSettingsToScrubSettings({\n    allowWheelScrub,\n    boundFeedback,\n    direction,\n    largeStep,\n    pixelSensitivity,\n    smallStep,\n    step,\n  })\n\n  const formatValue =\n    formatValueProp ??\n  (format\n    ? (nextValue: number) =>\n        new Intl.NumberFormat(undefined, format).format(nextValue)\n    : undefined)\n\n  const scrub = useNumberScrub({\n    disabled,\n    format: DEFAULT_FORMAT_SETTINGS,\n    formatValue,\n    logo,\n    max: normalizedMax,\n    min: normalizedMin,\n    onChange: setValue,\n    onValueCommit: onValueCommitted,\n    defaultResetValue: resetValue,\n    scrub: scrubSettings,\n    selectOnEdit: inputSettings.selectOnEdit,\n    shiftStep: largeStep,\n    step,\n    value,\n  })\n\n  const field = (\n    <div className=\"min-w-0\">\n      <ScrubNumberInput\n        {...props}\n        calligraph={calligraph}\n        className={cn(\n          grouped\n            ? \"min-w-0 flex-1\"\n            : logo.enabled\n              ? \"w-[6.75rem]\"\n              : \"w-[4.75rem]\",\n          className,\n        )}\n        disabled={disabled}\n        grouped={grouped}\n        inputClassName={inputClassName}\n        inputSettings={inputSettings}\n        logo={logo}\n        max={normalizedMax}\n        min={normalizedMin}\n        scrub={scrub}\n        scrubSettings={scrubSettings}\n      />\n    </div>\n  )\n\n  if (!label) {\n    return field\n  }\n\n  return (\n    <div className=\"flex items-center gap-3\">\n      <span\n        className={cn(\n          \"w-16 text-sm font-medium text-muted-foreground\",\n          labelClassName,\n        )}\n      >\n        {label}\n      </span>\n      {field}\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "@components/ui/scrub-number-input.tsx"
    },
    {
      "path": "components/ui/scrub-number-input.css",
      "content": "[data-slot=\"scrub-number-calligraph-value\"],\n[data-slot=\"scrub-number-calligraph-value\"] .scrub-number-calligraph {\n  line-height: 1;\n  overflow: hidden;\n}\n\n[data-slot=\"scrub-number-calligraph-value\"] {\n  color: inherit;\n  display: flex;\n  justify-content: flex-start;\n  position: relative;\n}\n\n[data-slot=\"scrub-number-calligraph-value\"] .scrub-number-calligraph,\n[data-slot=\"scrub-number-calligraph-value\"] .scrub-number-calligraph * {\n  color: inherit;\n}\n\n[data-slot=\"scrub-number-calligraph-value\"] .scrub-number-calligraph {\n  align-items: center !important;\n  justify-content: flex-start !important;\n}\n\n[data-slot=\"scrub-number-calligraph-value\"] .scrub-number-calligraph > span {\n  align-items: center !important;\n  display: inline-flex !important;\n  justify-content: flex-start !important;\n  margin: 0 !important;\n  overflow: hidden !important;\n  padding: 0 !important;\n}\n\n[data-slot=\"scrub-number-calligraph-value\"]\n  .scrub-number-calligraph\n  span[style*=\"vertical-align: top\"] {\n  align-items: center !important;\n  display: inline-flex !important;\n  height: 1em !important;\n  justify-content: flex-start !important;\n  line-height: 1 !important;\n  max-height: 1em !important;\n  overflow: hidden !important;\n  vertical-align: middle !important;\n}\n\n[data-slot=\"scrub-number-calligraph-value\"]\n  .scrub-number-calligraph\n  span[style*=\"visibility: hidden\"] {\n  font: inherit !important;\n  line-height: inherit !important;\n}\n\n[data-slot=\"scrub-number-scrubbable\"],\n[data-slot=\"input-group-control\"] {\n  text-align: start;\n}\n\n:root {\n  --scrub-bound-shake-distance: 6px;\n  --scrub-bound-shake-overshoot: 4px;\n  --scrub-bound-shake-dur-a: 80ms;\n  --scrub-bound-shake-dur-b: 60ms;\n  --scrub-bound-shake-ease: cubic-bezier(0.22, 1, 0.36, 1);\n  --scrub-bound-feedback-ms: calc(\n    var(--scrub-bound-shake-dur-a) * 2 + var(--scrub-bound-shake-dur-b) * 2\n  );\n  --scrub-bound-revert-hold: 600ms;\n  --scrub-bound-revert-dur: 280ms;\n  --scrub-bound-shake-color: var(--foreground);\n  --scrub-number-field-padding-inline: 0.5rem;\n}\n\n@keyframes scrub-bound-pulse {\n  0% {\n    box-shadow: 0 0 0 0 color-mix(in oklch, var(--scrub-bound-shake-color) 60%, transparent);\n  }\n\n  100% {\n    box-shadow: 0 0 0 3px color-mix(in oklch, var(--scrub-bound-shake-color) 0%, transparent);\n  }\n}\n\n[data-slot=\"scrub-bound-feedback\"][data-bound-hit] [data-slot=\"input-group\"],\n[data-slot=\"scrub-bound-feedback\"][data-bound-hit] [data-slot=\"scrub-number-scrubbable\"],\n[data-slot=\"scrub-bound-feedback\"][data-bound-hit] [data-slot=\"input-group-control\"] {\n  animation: scrub-bound-pulse var(--scrub-bound-feedback-ms) linear;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  [data-slot=\"scrub-bound-feedback\"][data-bound-hit] [data-slot=\"input-group\"],\n  [data-slot=\"scrub-bound-feedback\"][data-bound-hit] [data-slot=\"scrub-number-scrubbable\"],\n  [data-slot=\"scrub-bound-feedback\"][data-bound-hit] [data-slot=\"input-group-control\"] {\n    animation: none;\n    box-shadow: 0 0 0 2px var(--scrub-bound-shake-color);\n  }\n}\n\n.scrub-bound-field:not([data-slot=\"input-group\"]),\n[data-slot=\"scrub-number-scrubbable\"],\n[data-slot=\"input-group-control\"],\n[data-slot=\"scrub-bound-feedback\"] [data-slot=\"input-group\"] [data-slot=\"input-group-control\"] {\n  padding-inline: var(--scrub-number-field-padding-inline) !important;\n}\n\n.scrub-bound-field {\n  transition: border-color 150ms ease-out;\n  will-change: transform;\n}\n\n.scrub-bound-wrap.is-bound-error .scrub-bound-field {\n  border-color: var(--scrub-bound-shake-color);\n  transition: border-color var(--scrub-bound-revert-dur) ease-out;\n}\n\n.scrub-bound-field.is-shaking {\n  animation: scrub-bound-shake-x var(--scrub-bound-feedback-ms) linear;\n}\n\n.scrub-bound-wrap[data-bound-direction=\"vertical\"] .scrub-bound-field.is-shaking {\n  animation-name: scrub-bound-shake-y;\n}\n\n@keyframes scrub-bound-shake-x {\n  0% {\n    transform: translateX(0);\n    animation-timing-function: var(--scrub-bound-shake-ease);\n  }\n\n  28.57% {\n    transform: translateX(var(--scrub-bound-shake-distance));\n    animation-timing-function: var(--scrub-bound-shake-ease);\n  }\n\n  57.14% {\n    transform: translateX(calc(var(--scrub-bound-shake-distance) * -1));\n    animation-timing-function: var(--scrub-bound-shake-ease);\n  }\n\n  78.57% {\n    transform: translateX(var(--scrub-bound-shake-overshoot));\n    animation-timing-function: var(--scrub-bound-shake-ease);\n  }\n\n  100% {\n    transform: translateX(0);\n  }\n}\n\n@keyframes scrub-bound-shake-y {\n  0% {\n    transform: translateY(0);\n    animation-timing-function: var(--scrub-bound-shake-ease);\n  }\n\n  28.57% {\n    transform: translateY(var(--scrub-bound-shake-distance));\n    animation-timing-function: var(--scrub-bound-shake-ease);\n  }\n\n  57.14% {\n    transform: translateY(calc(var(--scrub-bound-shake-distance) * -1));\n    animation-timing-function: var(--scrub-bound-shake-ease);\n  }\n\n  78.57% {\n    transform: translateY(var(--scrub-bound-shake-overshoot));\n    animation-timing-function: var(--scrub-bound-shake-ease);\n  }\n\n  100% {\n    transform: translateY(0);\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .scrub-bound-field.is-shaking {\n    animation: none !important;\n    transform: none !important;\n  }\n}\n",
      "type": "registry:ui",
      "target": "@components/ui/scrub-number-input.css"
    },
    {
      "path": "lib/scrub-number-overflow.ts",
      "content": "import { useLayoutEffect, useState, type RefObject } from \"react\"\n\nfunction measureContentOverflow(\n  container: HTMLElement | null,\n  content: HTMLElement | null,\n) {\n  if (!container || !content) {\n    return false\n  }\n\n  const contentOverflows =\n    content.scrollWidth > content.clientWidth + 1 ||\n    content.getBoundingClientRect().width > container.clientWidth + 1\n\n  if (!contentOverflows) {\n    return false\n  }\n\n  return content.scrollWidth > container.clientWidth + 1\n}\n\nexport function useDisplayOverflowTruncated(\n  containerRef: RefObject<HTMLElement | null>,\n  deps: ReadonlyArray<unknown>,\n  contentRef?: RefObject<HTMLElement | null>,\n) {\n  const [isTruncated, setIsTruncated] = useState(false)\n\n  useLayoutEffect(() => {\n    const container = containerRef.current\n    const content = contentRef?.current ?? container\n\n    if (!container || !content) {\n      setIsTruncated(false)\n      return\n    }\n\n    const update = () => {\n      setIsTruncated(measureContentOverflow(container, content))\n    }\n\n    update()\n\n    if (typeof ResizeObserver === \"undefined\") {\n      return\n    }\n\n    const observer = new ResizeObserver(update)\n    observer.observe(container)\n    if (content !== container) {\n      observer.observe(content)\n    }\n\n    return () => {\n      observer.disconnect()\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- remeasure when display content changes\n  }, deps)\n\n  return isTruncated\n}\n",
      "type": "registry:lib",
      "target": "@lib/scrub-number-overflow.ts"
    },
    {
      "path": "lib/scrub-number-math.ts",
      "content": "export type DisplayFormat = {\n  alwaysShowSign: boolean\n}\n\nexport type ResolveActiveStepOptions = {\n  step: number\n  shiftStep: number\n  fineStep: number\n  coarse?: boolean\n  fine?: boolean\n}\n\nexport type ModifierKey = \"shift\" | \"alt\" | \"meta\"\n\nexport type FineModifier = \"shift\" | \"alt\" | \"meta\"\n\nexport type CoarseModifier = \"shift\" | \"alt\" | \"meta\"\n\nconst FINE_MODIFIER_KEYS = new Set<FineModifier>([\"shift\", \"alt\", \"meta\"])\nconst COARSE_MODIFIER_KEYS = new Set<CoarseModifier>([\"shift\", \"alt\", \"meta\"])\n\nexport type ModifierKeys = {\n  shiftKey: boolean\n  altKey: boolean\n  metaKey: boolean\n  getModifierState?: (key: string) => boolean\n}\n\nconst DEFAULT_SCRUB_THRESHOLD = 3\n\nexport function isMacPlatform() {\n  if (typeof navigator === \"undefined\") {\n    return false\n  }\n\n  const platform = navigator.platform ?? \"\"\n  const userAgent = navigator.userAgent ?? \"\"\n\n  return /Mac|iPhone|iPad|iPod/.test(platform) || /Mac OS X/.test(userAgent)\n}\n\nexport function isModifierKeyPressed(event: ModifierKeys, key: ModifierKey) {\n  const getState = event.getModifierState?.bind(event)\n\n  switch (key) {\n    case \"shift\":\n      return event.shiftKey || getState?.(\"Shift\") === true\n    case \"alt\":\n      return event.altKey || getState?.(\"Alt\") === true\n    case \"meta\":\n      return event.metaKey || getState?.(\"Meta\") === true\n  }\n}\n\nexport function normalizeFineModifier(\n  modifier: string | undefined,\n  fallback: FineModifier = \"alt\",\n): FineModifier {\n  if (modifier != null && FINE_MODIFIER_KEYS.has(modifier as FineModifier)) {\n    return modifier as FineModifier\n  }\n\n  if (modifier === \"auto\") {\n    return isMacPlatform() ? \"meta\" : \"alt\"\n  }\n\n  return fallback\n}\n\nexport function normalizeCoarseModifier(\n  modifier: string | undefined,\n  fallback: CoarseModifier = \"shift\",\n): CoarseModifier {\n  if (modifier != null && COARSE_MODIFIER_KEYS.has(modifier as CoarseModifier)) {\n    return modifier as CoarseModifier\n  }\n\n  if (modifier === \"auto\") {\n    return \"shift\"\n  }\n\n  return fallback\n}\n\nexport const MODIFIER_OPTIONS = [\"shift\", \"alt\", \"meta\"] as const satisfies readonly ModifierKey[]\n\nexport function pickAlternateModifier(\n  current: ModifierKey,\n  exclude: ModifierKey,\n): ModifierKey {\n  return MODIFIER_OPTIONS.find((option) => option !== exclude) ?? current\n}\n\nexport function resolveExclusiveModifiers(\n  fine: FineModifier,\n  coarse: CoarseModifier,\n): { fine: FineModifier; coarse: CoarseModifier } {\n  if (fine !== coarse) {\n    return { fine, coarse }\n  }\n\n  return {\n    fine,\n    coarse: pickAlternateModifier(coarse, fine) as CoarseModifier,\n  }\n}\n\nexport function resolveFineModifierKey(\n  modifier: FineModifier = \"alt\",\n): ModifierKey {\n  return modifier\n}\n\nexport function resolveCoarseModifierKey(\n  modifier: CoarseModifier = \"shift\",\n): ModifierKey {\n  return modifier\n}\n\nexport function isFineModifierPressed(\n  event: ModifierKeys,\n  modifier: FineModifier = \"alt\",\n) {\n  return isModifierKeyPressed(event, resolveFineModifierKey(modifier))\n}\n\nexport function isCoarseModifierPressed(\n  event: ModifierKeys,\n  modifier: CoarseModifier = \"shift\",\n) {\n  return isModifierKeyPressed(event, resolveCoarseModifierKey(modifier))\n}\n\nexport function getModifierLabel(key: ModifierKey) {\n  switch (key) {\n    case \"shift\":\n      return \"Shift\"\n    case \"alt\":\n      return \"Alt\"\n    case \"meta\":\n      return \"Cmd\"\n  }\n}\n\nexport function getFineModifierLabel(modifier: FineModifier = \"alt\") {\n  return getModifierLabel(resolveFineModifierKey(modifier))\n}\n\nexport function getCoarseModifierLabel(modifier: CoarseModifier = \"shift\") {\n  return getModifierLabel(resolveCoarseModifierKey(modifier))\n}\n\nexport type ScrubStepModifiers = {\n  coarse: boolean\n  fine: boolean\n}\n\nexport function toModifierKeys(event: {\n  shiftKey: boolean\n  altKey: boolean\n  metaKey: boolean\n  getModifierState?: (key: string) => boolean\n}): ModifierKeys {\n  return {\n    shiftKey: event.shiftKey,\n    altKey: event.altKey,\n    metaKey: event.metaKey,\n    getModifierState: event.getModifierState\n      ? (key) => event.getModifierState!(key)\n      : undefined,\n  }\n}\n\nexport function resolveScrubStepModifiers(\n  event: ModifierKeys,\n  options: {\n    fineModifier?: FineModifier\n    coarseModifier?: CoarseModifier\n  } = {},\n): ScrubStepModifiers {\n  const coarse = isCoarseModifierPressed(event, options.coarseModifier)\n  const fine = isFineModifierPressed(event, options.fineModifier)\n\n  return { coarse, fine }\n}\n\nexport function clampNumber(value: number, min?: number, max?: number) {\n  let bounded = value\n\n  if (min != null && Number.isFinite(min)) {\n    bounded = Math.max(min, bounded)\n  }\n\n  if (max != null && Number.isFinite(max)) {\n    bounded = Math.min(max, bounded)\n  }\n\n  return bounded\n}\n\nexport function normalizeFiniteNumber(\n  value: number | undefined,\n): number | undefined {\n  return typeof value === \"number\" && Number.isFinite(value) ? value : undefined\n}\n\nexport function normalizeNumberFieldBounds(\n  min?: number,\n  max?: number,\n): { min?: number; max?: number } {\n  let normalizedMin = normalizeFiniteNumber(min)\n  let normalizedMax = normalizeFiniteNumber(max)\n\n  if (\n    normalizedMin != null &&\n    normalizedMax != null &&\n    normalizedMin > normalizedMax\n  ) {\n    ;[normalizedMin, normalizedMax] = [normalizedMax, normalizedMin]\n  }\n\n  return { min: normalizedMin, max: normalizedMax }\n}\n\nexport function normalizePositiveFiniteStep(\n  step: number | undefined,\n  fallback = 1,\n) {\n  if (\n    typeof step === \"number\" &&\n    Number.isFinite(step) &&\n    step > 0\n  ) {\n    return step\n  }\n\n  return fallback\n}\n\nexport function normalizeWheelSensitivity(\n  sensitivity: number | undefined,\n  fallback = 20,\n) {\n  return clampNumber(\n    normalizePositiveFiniteStep(sensitivity, fallback),\n    1,\n    200,\n  )\n}\n\nexport function normalizeScrubThreshold(threshold: number | undefined, fallback = 3) {\n  const candidate =\n    typeof threshold === \"number\" && Number.isFinite(threshold)\n      ? threshold\n      : fallback\n\n  return clampNumber(candidate, 1, 20)\n}\n\nexport function getBoundEdge(\n  current: number,\n  attempted: number,\n  min?: number,\n  max?: number,\n): \"min\" | \"max\" | null {\n  if (attempted > current && max != null && current >= max) {\n    return \"max\"\n  }\n\n  if (attempted < current && min != null && current <= min) {\n    return \"min\"\n  }\n\n  return null\n}\n\nexport function getAtBound(\n  value: number,\n  min?: number,\n  max?: number,\n): \"min\" | \"max\" | null {\n  if (max != null && value >= max) {\n    return \"max\"\n  }\n\n  if (min != null && value <= min) {\n    return \"min\"\n  }\n\n  return null\n}\n\nexport function boundOverflow(\n  attempted: number,\n  edge: \"min\" | \"max\",\n  min?: number,\n  max?: number,\n) {\n  if (edge === \"max\" && max != null) {\n    return Math.max(0, attempted - max)\n  }\n\n  if (edge === \"min\" && min != null) {\n    return Math.max(0, min - attempted)\n  }\n\n  return 1\n}\n\nexport function quantizeNumber(value: number, step: number) {\n  if (!Number.isFinite(step) || step <= 0) {\n    return value\n  }\n\n  const quantized = Math.round(value / step) * step\n\n  if (Number.isInteger(step)) {\n    return quantized\n  }\n\n  const decimals = step.toString().split(\".\")[1]?.length ?? 0\n  return parseFloat(quantized.toFixed(decimals))\n}\n\nexport function getDecimalPlaces(step: number) {\n  if (!Number.isFinite(step) || Number.isInteger(step)) {\n    return 0\n  }\n\n  return step.toString().split(\".\")[1]?.length ?? 0\n}\n\nfunction toPlainNumberString(value: number) {\n  if (!Number.isFinite(value)) {\n    return String(value)\n  }\n\n  if (Object.is(value, -0)) {\n    return \"0\"\n  }\n\n  const str = value.toString()\n\n  if (!/[eE]/.test(str)) {\n    return str\n  }\n\n  return value.toLocaleString(\"en-US\", {\n    useGrouping: false,\n    maximumFractionDigits: 100,\n  })\n}\n\nexport function formatMinimalDisplayValue(value: number) {\n  const normalized = Number(value.toPrecision(12))\n\n  if (Number.isInteger(normalized)) {\n    return toPlainNumberString(Math.trunc(normalized))\n  }\n\n  return toPlainNumberString(normalized)\n}\n\nexport function countDraftDecimalPlaces(draft: string) {\n  const body = draft.trim().replace(/^[+-]/, \"\")\n\n  if (!body.includes(\".\")) {\n    return 0\n  }\n\n  return body.split(\".\")[1]?.length ?? 0\n}\n\nexport function resolveDisplayDecimalPlaces(\n  previousDraft: string,\n  userDecimalPlaces?: number | null,\n  activeStep?: number,\n) {\n  if (userDecimalPlaces != null && userDecimalPlaces > 0) {\n    return userDecimalPlaces\n  }\n\n  const fromDraft = countDraftDecimalPlaces(previousDraft)\n\n  if (fromDraft > 0) {\n    return fromDraft\n  }\n\n  const fromStep = activeStep != null ? getDecimalPlaces(activeStep) : 0\n\n  return fromStep > 0 ? fromStep : null\n}\n\nexport function formatDisplayValue(\n  value: number,\n  format: Pick<DisplayFormat, \"alwaysShowSign\">,\n  userDecimalPlaces: number | null = null,\n) {\n  const formatted =\n    userDecimalPlaces != null && userDecimalPlaces > 0\n      ? value.toFixed(userDecimalPlaces)\n      : formatMinimalDisplayValue(value)\n\n  if (format.alwaysShowSign && value > 0) {\n    return `+${formatted}`\n  }\n\n  return formatted\n}\n\nexport function resolveActiveStep(options: ResolveActiveStepOptions) {\n  const { step, shiftStep, fineStep, coarse = false, fine = false } = options\n\n  if (coarse) {\n    return shiftStep\n  }\n\n  if (fine) {\n    return fineStep\n  }\n\n  return step\n}\n\nconst DEFAULT_WHEEL_LINE_DELTA = 16\nconst DEFAULT_WHEEL_PAGE_DELTA = 100\n\nexport function normalizeWheelDelta(deltaY: number, deltaMode = 0) {\n  switch (deltaMode) {\n    case 1:\n      return deltaY * DEFAULT_WHEEL_LINE_DELTA\n    case 2:\n      return deltaY * DEFAULT_WHEEL_PAGE_DELTA\n    default:\n      return deltaY\n  }\n}\n\nexport type ConsumeWheelDeltaResult = {\n  accumulated: number\n  direction: 0 | 1 | -1\n  steps: number\n}\n\nexport function consumeWheelDelta(\n  accumulated: number,\n  deltaY: number,\n  sensitivity: number,\n): ConsumeWheelDeltaResult {\n  const next = accumulated + deltaY\n  const abs = Math.abs(next)\n  const threshold = Math.max(1, sensitivity)\n\n  if (abs < threshold) {\n    return { accumulated: next, steps: 0, direction: 0 }\n  }\n\n  const steps = Math.floor(abs / threshold)\n  const sign = next < 0 ? -1 : 1\n  const remainder = next - sign * steps * threshold\n  const direction = (next < 0 ? 1 : -1) as 1 | -1\n\n  return { accumulated: remainder, steps, direction }\n}\n\nexport function getValueDecimalPlaces(value: number) {\n  if (!Number.isFinite(value)) {\n    return 0\n  }\n\n  const normalized = Number(value.toPrecision(12))\n\n  if (Number.isInteger(normalized)) {\n    return 0\n  }\n\n  const text = normalized.toString()\n\n  if (text.includes(\"e\") || text.includes(\"E\")) {\n    return getValueDecimalPlaces(Number(normalized.toFixed(12)))\n  }\n\n  return text.split(\".\")[1]?.length ?? 0\n}\n\nexport function stepFromDecimalPlaces(decimals: number) {\n  if (decimals <= 0) {\n    return 1\n  }\n\n  return Number((1 / 10 ** decimals).toFixed(decimals))\n}\n\nexport function resolveQuantizeStep(options: {\n  step: number\n  fineStep: number\n  fine?: boolean\n  currentValue: number\n  userDecimalPlaces?: number | null\n}) {\n  const activeStep = options.fine ? options.fineStep : options.step\n  const valuePrecision = Math.max(\n    getValueDecimalPlaces(options.currentValue),\n    options.userDecimalPlaces ?? 0,\n  )\n  const stepPrecision = getDecimalPlaces(activeStep)\n\n  if (valuePrecision > stepPrecision) {\n    return stepFromDecimalPlaces(valuePrecision)\n  }\n\n  return activeStep\n}\n\nexport function applyStepDelta(\n  current: number,\n  delta: number,\n  options: {\n    step: number\n    fineStep: number\n    fine?: boolean\n    userDecimalPlaces?: number | null\n  },\n) {\n  const next = current + delta\n\n  if (options.userDecimalPlaces != null && options.userDecimalPlaces > 0) {\n    return parseFloat(next.toFixed(options.userDecimalPlaces))\n  }\n\n  const quantizeStep = resolveQuantizeStep({\n    step: options.step,\n    fineStep: options.fineStep,\n    fine: options.fine,\n    currentValue: current,\n    userDecimalPlaces: options.userDecimalPlaces,\n  })\n\n  return quantizeNumber(next, quantizeStep)\n}\n\nexport function preserveDisplayDraft(\n  currentDraft: string,\n  value: number,\n  fallback: string,\n) {\n  const parsed = Number(currentDraft.replace(/^\\+/, \"\"))\n\n  if (currentDraft !== \"\" && Number.isFinite(parsed) && parsed === value) {\n    return currentDraft\n  }\n\n  return fallback\n}\n\nexport function getScrubPointerDelta(\n  event: { clientX: number; clientY: number },\n  startX: number,\n  startY: number,\n  direction: \"horizontal\" | \"vertical\",\n) {\n  if (direction === \"vertical\") {\n    return startY - event.clientY\n  }\n\n  return event.clientX - startX\n}\n\nexport function hasExceededScrubThreshold(\n  event: { clientX: number; clientY: number },\n  startX: number,\n  startY: number,\n  direction: \"horizontal\" | \"vertical\",\n  threshold = DEFAULT_SCRUB_THRESHOLD,\n) {\n  const effectiveThreshold = Math.max(1, threshold)\n\n  if (direction === \"vertical\") {\n    return Math.abs(event.clientY - startY) > effectiveThreshold\n  }\n\n  return Math.abs(event.clientX - startX) > effectiveThreshold\n}\n\nexport function resolveFineStep(step: number, fineStep?: number) {\n  if (fineStep != null && Number.isFinite(fineStep) && fineStep > 0) {\n    return fineStep\n  }\n\n  return quantizeNumber(step / 10, step) || step / 10\n}\n\nexport function sanitizeNumericDraft(value: string, previousValue = \"\") {\n  if (value === \"\") {\n    return \"\"\n  }\n\n  if (/^[+-]{2,}/.test(value)) {\n    return previousValue\n  }\n\n  if (/[eE]/.test(value) || (value.match(/\\./g)?.length ?? 0) > 1) {\n    return previousValue\n  }\n\n  let sign = \"\"\n  let rest = value\n\n  if (rest.startsWith(\"+\") || rest.startsWith(\"-\")) {\n    sign = rest[0]\n    rest = rest.slice(1)\n  }\n\n  let hasDot = false\n  let body = \"\"\n\n  for (const character of rest) {\n    if (character >= \"0\" && character <= \"9\") {\n      body += character\n      continue\n    }\n\n    if (character === \".\" && !hasDot) {\n      hasDot = true\n      body += character\n    }\n  }\n\n  const sanitized = sign + body\n\n  return sanitized\n}\n",
      "type": "registry:lib",
      "target": "@lib/scrub-number-math.ts"
    },
    {
      "path": "hooks/use-controllable-state.tsx",
      "content": "import * as React from \"react\"\n\nconst useLayoutEffect = globalThis?.document ? React.useLayoutEffect : () => {}\n\nconst useInsertionEffect: typeof useLayoutEffect =\n  (React as never)[\" useInsertionEffect \".trim().toString()] || useLayoutEffect\n\ntype ChangeHandler<T> = (state: T) => void\ntype SetStateFn<T> = React.Dispatch<React.SetStateAction<T>>\n\ninterface UseControllableStateParams<T> {\n  prop?: T | undefined\n  defaultProp: T\n  onChange?: ChangeHandler<T>\n  caller?: string\n}\n\nexport function useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange = () => {},\n  caller,\n}: UseControllableStateParams<T>): [T, SetStateFn<T>] {\n  const [uncontrolledProp, setUncontrolledProp, onChangeRef] =\n    useUncontrolledState({\n      defaultProp,\n      onChange,\n    })\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolledProp\n\n  /* eslint-disable react-hooks/rules-of-hooks */\n  if (process.env.NODE_ENV !== \"production\") {\n    const isControlledRef = React.useRef(prop !== undefined)\n    React.useEffect(() => {\n      const wasControlled = isControlledRef.current\n      if (wasControlled !== isControlled) {\n        const from = wasControlled ? \"controlled\" : \"uncontrolled\"\n        const to = isControlled ? \"controlled\" : \"uncontrolled\"\n        console.warn(\n          `${caller} is changing from ${from} to ${to}. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled value for the lifetime of the component.`\n        )\n      }\n      isControlledRef.current = isControlled\n    }, [isControlled, caller])\n  }\n  /* eslint-enable react-hooks/rules-of-hooks */\n\n  const setValue = React.useCallback<SetStateFn<T>>(\n    (nextValue) => {\n      if (isControlled) {\n        const value = isFunction(nextValue) ? nextValue(prop) : nextValue\n        if (value !== prop) {\n          onChangeRef.current?.(value)\n        }\n      } else {\n        setUncontrolledProp(nextValue)\n      }\n    },\n    [isControlled, prop, setUncontrolledProp, onChangeRef]\n  )\n\n  return [value, setValue]\n}\n\nfunction useUncontrolledState<T>({\n  defaultProp,\n  onChange,\n}: Omit<UseControllableStateParams<T>, \"prop\">): [\n  Value: T,\n  setValue: React.Dispatch<React.SetStateAction<T>>,\n  OnChangeRef: React.RefObject<ChangeHandler<T> | undefined>,\n] {\n  const [value, setValue] = React.useState(defaultProp)\n  const prevValueRef = React.useRef(value)\n\n  const onChangeRef = React.useRef(onChange)\n  useInsertionEffect(() => {\n    onChangeRef.current = onChange\n  }, [onChange])\n\n  React.useEffect(() => {\n    if (prevValueRef.current !== value) {\n      onChangeRef.current?.(value)\n      prevValueRef.current = value\n    }\n  }, [value, prevValueRef])\n\n  return [value, setValue, onChangeRef]\n}\n\nfunction isFunction(value: unknown): value is (...args: never[]) => unknown {\n  return typeof value === \"function\"\n}\n",
      "type": "registry:hook",
      "target": "@hooks/use-controllable-state.tsx"
    }
  ],
  "docs": "https://kinetic.itsjay.in/demo",
  "categories": [
    "controls",
    "inputs"
  ],
  "type": "registry:ui"
}