{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-stepper",
  "type": "registry:ui",
  "title": "Number Stepper",
  "description": "A numeric field with − / + buttons, accelerating hold-to-repeat, rolling digits and an elastic bump at the limits.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/number-stepper.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue } from \"motion/react\";\n\nexport interface NumberStepperProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\" | \"children\"> {\n  /** Visible label and accessible name of the spinbutton. */\n  label?: string;\n  /** Keep the label for assistive technology only. */\n  hideLabel?: boolean;\n  /** Controlled value. */\n  value?: number;\n  /** Initial value when uncontrolled. */\n  defaultValue?: number;\n  /** Called whenever the committed value changes. */\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** Step for Page Up / Page Down. Defaults to 10 × step. */\n  largeStep?: number;\n  /** Fraction digits. Defaults to the number of decimals in `step`. */\n  precision?: number;\n  /** Text before the number, e.g. \"$\". */\n  prefix?: string;\n  /** Text after the number, e.g. \" kg\". */\n  suffix?: string;\n  /** Full formatter (overrides prefix / suffix / locale). Digits roll, other characters stay put. */\n  formatValue?: (value: number) => string;\n  /** Locale for digit grouping. */\n  locale?: string;\n  /** Focus ring and active colour. */\n  accent?: string;\n  size?: \"sm\" | \"md\" | \"lg\";\n  disabled?: boolean;\n  /** Submit the value with a form through a hidden input. */\n  name?: string;\n  /** id of the input (generated when omitted). */\n  inputId?: string;\n}\n\nconst SIZES = {\n  sm: { row: \"h-9\", text: \"text-sm\", button: \"w-9\", icon: \"size-3\" },\n  md: { row: \"h-11\", text: \"text-base\", button: \"w-11\", icon: \"size-3.5\" },\n  lg: { row: \"h-14\", text: \"text-xl\", button: \"w-14\", icon: \"size-4\" },\n} as const;\n\nconst HOLD_DELAY = 400; // ms before auto-repeat starts\nconst FIRST_INTERVAL = 140; // ms between the first repeats\nconst MIN_INTERVAL = 28; // fastest repeat\nconst ACCELERATION = 0.86; // interval multiplier per repeat\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst decimalsOf = (n: number) => {\n  const s = String(n);\n  if (s.includes(\"e-\")) return Number(s.split(\"e-\")[1]);\n  return (s.split(\".\")[1] ?? \"\").length;\n};\n\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\n\nconst digitVariants = {\n  enter: (dir: number) => ({ y: dir > 0 ? \"70%\" : \"-70%\", opacity: 0 }),\n  center: { y: \"0%\", opacity: 1 },\n  exit: (dir: number) => ({ y: dir > 0 ? \"-70%\" : \"70%\", opacity: 0 }),\n};\n\nfunction DigitSlot({ digit, dir }: { digit: string; dir: number }) {\n  return (\n    <span className=\"relative inline-flex h-[1.25em] overflow-hidden leading-[1.25em]\">\n      <AnimatePresence initial={false} custom={dir} mode=\"popLayout\">\n        <motion.span\n          key={digit}\n          custom={dir}\n          variants={digitVariants}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n          transition={{ type: \"spring\", stiffness: 520, damping: 38 }}\n          className=\"inline-block\"\n        >\n          {digit}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction StepGlyph({ plus, className }: { plus: boolean; className: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className={className}>\n      <path d={plus ? \"M8 3v10M3 8h10\" : \"M3 8h10\"} stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\n/**\n * A numeric input flanked by − / + buttons. Holding a button repeats with acceleration, digits roll\n * in the direction of change, and pushing past min / max gives an elastic bump. The text field is a\n * role=\"spinbutton\" with the full keyboard model, and typed values are validated on blur.\n */\nexport function NumberStepper({\n  label = \"Quantity\",\n  hideLabel = false,\n  value: valueProp,\n  defaultValue = 0,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  largeStep,\n  precision,\n  prefix = \"\",\n  suffix = \"\",\n  formatValue,\n  locale = \"en-US\",\n  accent = \"#FF7448\",\n  size = \"md\",\n  disabled = false,\n  name,\n  inputId,\n  className = \"\",\n  style,\n  ...props\n}: NumberStepperProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const autoId = React.useId();\n  const id = inputId ?? `${autoId}-input`;\n  const statusId = `${autoId}-status`;\n\n  const lo = Math.min(min, max);\n  const hi = Math.max(min, max);\n  const safeStep = step > 0 ? step : 1;\n  const digits = Math.max(0, Math.min(10, precision ?? decimalsOf(safeStep)));\n  const pageStep = largeStep ?? safeStep * 10;\n  const normalise = React.useCallback(\n    (n: number) => Number(clamp(n, lo, hi).toFixed(digits)),\n    [lo, hi, digits],\n  );\n\n  const controlled = valueProp !== undefined;\n  const [inner, setInner] = React.useState(() => normalise(defaultValue));\n  const current = normalise(controlled ? valueProp : inner);\n\n  const numberFormat = React.useMemo(() => {\n    const options = { minimumFractionDigits: digits, maximumFractionDigits: digits };\n    try {\n      return new Intl.NumberFormat(locale, options);\n    } catch {\n      return new Intl.NumberFormat(\"en-US\", options);\n    }\n  }, [locale, digits]);\n  const format = React.useCallback(\n    (n: number) => (formatValue ? formatValue(n) : `${prefix}${numberFormat.format(n)}${suffix}`),\n    [formatValue, prefix, suffix, numberFormat],\n  );\n  const formatted = format(current);\n\n  // Direction of the last change, derived during render (no effect round-trip).\n  const [roll, setRoll] = React.useState({ value: current, dir: 1 });\n  if (roll.value !== current) setRoll({ value: current, dir: current > roll.value ? 1 : -1 });\n\n  /** Text being typed; null while the rolling display is shown. */\n  const [draft, setDraft] = React.useState<string | null>(null);\n  const [invalid, setInvalid] = React.useState(false);\n  const [status, setStatus] = React.useState(\"\");\n\n  const latest = React.useRef(current);\n  React.useEffect(() => {\n    latest.current = current;\n  }, [current]);\n\n  const bump = useMotionValue(0);\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const repeatTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const selectOnMouseUp = React.useRef(false);\n\n  React.useEffect(() => () => clearTimeout(repeatTimer.current), []);\n\n  const kick = (dir: number) => {\n    if (reduceMotion) return;\n    bump.stop();\n    animate(bump, 0, { type: \"spring\", velocity: dir * 260, stiffness: 700, damping: 15 });\n  };\n\n  const commit = (next: number) => {\n    const n = normalise(next);\n    if (n === latest.current) return;\n    latest.current = n;\n    if (!controlled) setInner(n);\n    onValueChange?.(n);\n  };\n\n  /** Parses the draft (if any) and returns the value to step from. */\n  const settleDraft = () => {\n    if (draft === null) return latest.current;\n    setDraft(null);\n    const parsed = parse(draft);\n    if (parsed === null) return latest.current;\n    commit(parsed);\n    return latest.current;\n  };\n\n  /** Moves by `delta`; returns false (and bumps) when already at the limit. */\n  const stepBy = (delta: number) => {\n    const base = settleDraft();\n    const target = normalise(base + delta);\n    if (target === base) {\n      kick(delta > 0 ? 1 : -1);\n      return false;\n    }\n    setInvalid(false);\n    commit(target);\n    return true;\n  };\n\n  const jumpTo = (target: number) => {\n    const base = settleDraft();\n    if (normalise(target) === base) kick(target > base ? 1 : -1);\n    else commit(target);\n  };\n\n  const stopRepeat = () => clearTimeout(repeatTimer.current);\n\n  const startRepeat = (dir: 1 | -1) => {\n    stopRepeat();\n    if (!stepBy(dir * safeStep)) return;\n    let interval = FIRST_INTERVAL;\n    const tick = () => {\n      if (!stepBy(dir * safeStep)) return;\n      interval = Math.max(MIN_INTERVAL, interval * ACCELERATION);\n      repeatTimer.current = setTimeout(tick, interval);\n    };\n    repeatTimer.current = setTimeout(tick, HOLD_DELAY);\n  };\n\n  function parse(text: string): number | null {\n    const cleaned = text.replace(/−/g, \"-\").replace(/[^\\d.-]/g, \"\");\n    if (!/^-?(\\d+\\.?\\d*|\\.\\d+)$/.test(cleaned)) return null;\n    const n = Number(cleaned);\n    return Number.isFinite(n) ? n : null;\n  }\n\n  const finishEditing = () => {\n    if (draft === null) return;\n    const parsed = parse(draft);\n    setDraft(null);\n    if (parsed === null) {\n      setInvalid(true);\n      setStatus(`Not a number. Kept ${formatted}.`);\n      kick(1);\n      return;\n    }\n    setInvalid(false);\n    const n = normalise(parsed);\n    setStatus(n !== parsed ? `Adjusted to ${format(n)}.` : \"\");\n    commit(n);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    if (disabled) return;\n    const handled = () => event.preventDefault();\n    switch (event.key) {\n      case \"ArrowUp\":\n        handled();\n        stepBy(safeStep);\n        break;\n      case \"ArrowDown\":\n        handled();\n        stepBy(-safeStep);\n        break;\n      case \"PageUp\":\n        handled();\n        stepBy(pageStep);\n        break;\n      case \"PageDown\":\n        handled();\n        stepBy(-pageStep);\n        break;\n      case \"Home\":\n        if (Number.isFinite(lo)) {\n          handled();\n          jumpTo(lo);\n        }\n        break;\n      case \"End\":\n        if (Number.isFinite(hi)) {\n          handled();\n          jumpTo(hi);\n        }\n        break;\n      case \"Enter\":\n        finishEditing();\n        break;\n      case \"Escape\":\n        if (draft !== null) {\n          handled();\n          setDraft(null);\n        }\n        break;\n    }\n  };\n\n  const sizes = SIZES[size] ?? SIZES.md;\n  const editing = draft !== null;\n  const width = Math.min(18, Math.max(4, format(lo).length, format(hi).length) + 1.5);\n  const atMin = current <= lo;\n  const atMax = current >= hi;\n  const chars = formatted.split(\"\");\n\n  const stepButton = (dir: 1 | -1) => {\n    const atLimit = dir > 0 ? atMax : atMin;\n    return (\n      <button\n        type=\"button\"\n        // Keyboard users step from the spinbutton itself (the WAI-ARIA pattern), so the buttons skip the tab order.\n        tabIndex={-1}\n        aria-label={dir > 0 ? \"Increase\" : \"Decrease\"}\n        aria-controls={id}\n        aria-disabled={atLimit || disabled || undefined}\n        disabled={disabled}\n        onPointerDown={(event) => {\n          if (event.button !== 0 || disabled) return;\n          event.preventDefault(); // keep focus where it is (no mobile keyboard pop-up)\n          event.currentTarget.setPointerCapture(event.pointerId);\n          startRepeat(dir);\n        }}\n        onPointerUp={stopRepeat}\n        onPointerCancel={stopRepeat}\n        onLostPointerCapture={stopRepeat}\n        onClick={(event) => {\n          // detail === 0: activated by assistive technology or a synthetic click, not a pointer.\n          if (event.detail === 0 && !disabled) stepBy(dir * safeStep);\n        }}\n        onContextMenu={(event) => event.preventDefault()}\n        className={`grid h-full shrink-0 touch-manipulation select-none place-items-center text-zinc-600 transition-[color,background-color,opacity] duration-150 hover:bg-zinc-900/[0.04] hover:text-zinc-950 active:bg-zinc-900/[0.08] disabled:cursor-not-allowed dark:text-zinc-400 dark:hover:bg-white/[0.05] dark:hover:text-white dark:active:bg-white/[0.09] ${sizes.button} ${atLimit ? \"opacity-35\" : \"\"}`}\n      >\n        <StepGlyph plus={dir > 0} className={sizes.icon} />\n      </button>\n    );\n  };\n\n  return (\n    <div\n      className={`inline-flex flex-col gap-2 ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n      style={{ [\"--ns-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <label\n        htmlFor={id}\n        className={hideLabel ? \"sr-only\" : \"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\"}\n      >\n        {label}\n      </label>\n      <div\n        className={`inline-flex items-stretch overflow-hidden rounded-full border bg-white/70 transition-[border-color,box-shadow] duration-150 focus-within:border-[var(--ns-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ns-accent)_22%,transparent)] dark:bg-white/[0.03] ${sizes.row} ${invalid ? \"border-rose-500/70 dark:border-rose-400/70\" : \"border-zinc-900/15 dark:border-white/12\"}`}\n      >\n        {stepButton(-1)}\n        <motion.div\n          className={`relative border-x border-zinc-900/10 font-medium tabular-nums tracking-tight text-zinc-950 dark:border-white/10 dark:text-zinc-50 ${sizes.text}`}\n          style={{ x: bump, width: `${width}ch` }}\n        >\n          <input\n            ref={inputRef}\n            id={id}\n            type=\"text\"\n            inputMode={digits > 0 ? \"decimal\" : \"numeric\"}\n            role=\"spinbutton\"\n            autoComplete=\"off\"\n            spellCheck={false}\n            disabled={disabled}\n            aria-valuenow={current}\n            aria-valuemin={Number.isFinite(lo) ? lo : undefined}\n            aria-valuemax={Number.isFinite(hi) ? hi : undefined}\n            aria-valuetext={formatted}\n            aria-invalid={invalid || undefined}\n            aria-describedby={statusId}\n            value={editing ? draft : current.toFixed(digits)}\n            onChange={(event) => {\n              setDraft(event.target.value);\n              setInvalid(false);\n            }}\n            onKeyDown={onKeyDown}\n            onPointerDown={(event) => {\n              selectOnMouseUp.current = document.activeElement !== event.currentTarget;\n            }}\n            onMouseUp={(event) => {\n              // Keep the select-all from focus instead of collapsing it to the click position.\n              if (selectOnMouseUp.current) event.preventDefault();\n              selectOnMouseUp.current = false;\n            }}\n            onFocus={(event) => event.currentTarget.select()}\n            onBlur={finishEditing}\n            className={`absolute inset-0 size-full bg-transparent text-center outline-none disabled:cursor-not-allowed ${editing ? \"\" : \"text-transparent caret-transparent selection:bg-transparent\"}`}\n          />\n          {/* The rolling display sits over the (transparent) input until the user starts typing. */}\n          <span\n            aria-hidden=\"true\"\n            className={`pointer-events-none absolute inset-0 flex items-center justify-center whitespace-nowrap ${editing ? \"invisible\" : \"\"}`}\n          >\n            {reduceMotion\n              ? formatted\n              : chars.map((ch, i) => {\n                  const key = chars.length - i;\n                  return /\\d/.test(ch) ? <DigitSlot key={key} digit={ch} dir={roll.dir} /> : <span key={key} className=\"whitespace-pre\">{ch}</span>;\n                })}\n          </span>\n        </motion.div>\n        {stepButton(1)}\n      </div>\n      {name ? <input type=\"hidden\" name={name} value={current} /> : null}\n      <span id={statusId} role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {status}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/number-stepper"
}
