{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-input",
  "title": "Number Input",
  "description": "A field for amounts that clamps to its range, repeats while a button is held, reads formatted text back, and comes as chevrons, split, pill or one big number.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/number-input.tsx",
      "content": "import type * as React from \"react\"\nimport { ChevronDown, ChevronUp, Minus, Plus } from \"lucide-react\"\nimport { fittedFontSize } from \"@/registry/miseui/ui/number-input-math\"\nimport { RollingText, StepButton } from \"@/registry/miseui/ui/number-input-parts\"\nimport { useNumberInputState, type NumberInputState, type NumberInputStateOptions } from \"@/registry/miseui/ui/number-input-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype NumberInputVariant = \"plain\" | \"split\" | \"stepper-pill\" | \"large\"\ntype NumberInputEffect = \"roll\"\n\nconst FOCUS_RING_CLASS =\n  \"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 has-[[aria-invalid=true]]:border-destructive\"\nconst FRAME_CLASS = \"h-9 w-full overflow-hidden rounded-md border border-input shadow-xs pointer-coarse:h-10\"\nconst QUIET_STEP_CLASS = \"text-muted-foreground hover:bg-accent hover:text-foreground\"\n\nconst ROOT_CLASS: Record<NumberInputVariant, string> = {\n  plain: cn(\"flex items-stretch\", FRAME_CLASS, FOCUS_RING_CLASS),\n  split: cn(\"flex items-stretch divide-x divide-input\", FRAME_CLASS, FOCUS_RING_CLASS),\n  \"stepper-pill\": \"inline-flex h-9 w-fit items-center gap-1 rounded-full bg-muted p-1 focus-within:ring-[3px] focus-within:ring-ring/50 pointer-coarse:h-10\",\n  large: \"flex w-full max-w-xs items-center justify-between gap-4\",\n}\n\nconst TEXT_CLASS: Record<NumberInputVariant, string> = {\n  plain: \"px-3 text-base md:text-sm\",\n  split: \"px-2 text-base md:text-sm\",\n  \"stepper-pill\": \"text-base font-medium md:text-sm\",\n  large: \"text-5xl font-medium tracking-tight\",\n}\n\nconst FIELD_CLASS: Record<NumberInputVariant, string> = {\n  plain: \"text-left\",\n  split: \"text-center\",\n  \"stepper-pill\": \"text-center\",\n  large: \"h-12 rounded-md text-center focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n}\n\nconst STEP_CLASS: Record<NumberInputVariant, string> = {\n  plain: cn(\"w-7 flex-1 pointer-coarse:w-10\", QUIET_STEP_CLASS),\n  split: cn(\"w-9 pointer-coarse:w-10\", QUIET_STEP_CLASS),\n  \"stepper-pill\":\n    \"size-7 rounded-full bg-background text-foreground shadow-xs hover:bg-background/70 pointer-coarse:size-8 pointer-coarse:after:absolute pointer-coarse:after:-inset-1\",\n  large: \"size-10 rounded-full border border-input text-foreground hover:bg-accent\",\n}\n\ntype NumberInputProps = Omit<\n  React.ComponentProps<\"input\">,\n  \"value\" | \"defaultValue\" | \"onChange\" | \"type\" | \"min\" | \"max\" | \"step\" | \"size\" | \"children\"\n> &\n  Pick<NumberInputStateOptions, \"value\" | \"defaultValue\" | \"onValueChange\" | \"min\" | \"max\" | \"step\" | \"largeStep\" | \"locale\" | \"format\"> & {\n    /** `plain` chevrons at the end, `split` minus and plus on both sides, `stepper-pill` compact pill, `large` one big number. */\n    variant?: NumberInputVariant\n    /** `roll` turns the digits like an odometer when the value changes, and steps aside while someone types. */\n    effect?: NumberInputEffect\n    incrementLabel?: string\n    decrementLabel?: string\n    /** A line under the number on `large`, such as the unit. */\n    caption?: React.ReactNode\n  }\n\n/** A phone keypad with no minus key would trap anyone who needs a negative number. */\nfunction keypadFor(min: number | undefined, step = 1) {\n  if (min === undefined || min < 0) return \"text\"\n  return step % 1 === 0 ? \"numeric\" : \"decimal\"\n}\n\ninterface NumberFieldProps {\n  state: NumberInputState\n  variant: NumberInputVariant\n  roll: boolean\n  bounds: { min?: number; max?: number; step?: number }\n  inputProps: Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\" | \"onChange\" | \"type\" | \"min\" | \"max\" | \"step\" | \"size\" | \"children\">\n}\n\nfunction NumberField({ state, variant, roll, bounds, inputProps }: NumberFieldProps) {\n  const { style, onBlur, onKeyDown, ...rest } = inputProps\n  const rolling = roll && state.draft === null && state.formatted !== \"\"\n  // The pill hugs its digits. Without a width an input keeps its default 20 characters and the pill stops being compact.\n  const width = variant === \"stepper-pill\" ? { width: `${Math.max(state.text.length, 2) + 1}ch` } : undefined\n  const fit = variant === \"large\" ? { fontSize: fittedFontSize(state.text.length) } : undefined\n  return (\n    <span className={cn(\"relative block min-w-0\", variant === \"stepper-pill\" ? \"shrink-0\" : \"flex-1\", fit && \"@container\")}>\n      <input\n        {...rest}\n        type=\"text\"\n        role=\"spinbutton\"\n        inputMode={keypadFor(bounds.min, bounds.step)}\n        autoComplete=\"off\"\n        aria-valuenow={state.current ?? undefined}\n        aria-valuemin={bounds.min}\n        aria-valuemax={bounds.max}\n        aria-valuetext={state.formatted || undefined}\n        data-slot=\"number-input-field\"\n        value={state.text}\n        style={{ ...width, ...fit, ...style }}\n        className={cn(\n          \"h-full w-full min-w-0 bg-transparent tabular-nums outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed\",\n          TEXT_CLASS[variant],\n          FIELD_CLASS[variant],\n          rolling && \"text-transparent caret-foreground\",\n        )}\n        onChange={state.onChange}\n        onBlur={(event) => {\n          state.onBlur()\n          onBlur?.(event)\n        }}\n        onKeyDown={(event) => {\n          state.onKeyDown(event)\n          onKeyDown?.(event)\n        }}\n      />\n      {rolling ? <RollingText text={state.formatted} style={fit} className={cn(TEXT_CLASS[variant], variant === \"plain\" ? \"justify-start\" : \"justify-center\")} /> : null}\n    </span>\n  )\n}\n\nfunction NumberInput({\n  variant = \"plain\",\n  effect,\n  incrementLabel = \"Increase\",\n  decrementLabel = \"Decrease\",\n  caption,\n  name,\n  className,\n  ...props\n}: NumberInputProps) {\n  const { value, defaultValue, onValueChange, min, max, step, largeStep, locale, format, ...inputProps } = props\n  const { disabled = false, readOnly = false } = inputProps\n  const state = useNumberInputState({ value, defaultValue, onValueChange, min, max, step, largeStep, locale, format, readOnly, disabled })\n  const stepClass = STEP_CLASS[variant]\n  const plain = variant === \"plain\"\n  const decrease = (\n    <StepButton aria-label={decrementLabel} disabled={disabled || readOnly || state.atMin} className={stepClass} onStep={state.decrement}>\n      {plain ? <ChevronDown /> : <Minus />}\n    </StepButton>\n  )\n  const increase = (\n    <StepButton aria-label={incrementLabel} disabled={disabled || readOnly || state.atMax} className={stepClass} onStep={state.increment}>\n      {plain ? <ChevronUp /> : <Plus />}\n    </StepButton>\n  )\n  const field = <NumberField state={state} variant={variant} roll={effect === \"roll\"} bounds={{ min, max, step }} inputProps={inputProps} />\n  return (\n    <div data-slot=\"number-input\" data-variant={variant} data-effect={effect} className={cn(ROOT_CLASS[variant], disabled && \"cursor-not-allowed opacity-50\", className)}>\n      {name ? <input type=\"hidden\" name={name} value={state.current ?? \"\"} disabled={disabled} /> : null}\n      {plain ? field : decrease}\n      {variant === \"large\" ? (\n        <span className=\"grid min-w-0 flex-1 gap-1 text-center\">\n          {field}\n          {caption ? <span className=\"text-[13px] text-muted-foreground\">{caption}</span> : null}\n        </span>\n      ) : null}\n      {variant === \"split\" || variant === \"stepper-pill\" ? field : null}\n      {plain ? (\n        <span className=\"flex flex-col-reverse divide-y divide-y-reverse divide-input border-l border-input pointer-coarse:flex-row pointer-coarse:divide-x pointer-coarse:divide-y-0\">\n          {decrease}\n          {increase}\n        </span>\n      ) : (\n        increase\n      )}\n    </div>\n  )\n}\n\nexport { NumberInput, type NumberInputProps, type NumberInputVariant, type NumberInputEffect }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/number-input-parts.tsx",
      "content": "import type * as React from \"react\"\nimport { useEffect, useRef } from \"react\"\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { shortestDigitDelta, wheelOffset } from \"@/registry/miseui/ui/number-input-math\"\nimport { useHoldRepeat } from \"@/registry/miseui/ui/number-input-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nconst WHEEL_FACES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const\n\ntype StepButtonProps = Omit<React.ComponentProps<\"button\">, \"onClick\" | \"type\"> & {\n  onStep: () => void\n}\n\n/**\n * Out of the tab order on purpose: the field already takes the arrow keys, which is the spinbutton pattern.\n * `detail === 0` is a click with no pointer behind it, from a screen reader, which pointerdown never sees.\n */\nfunction StepButton({ onStep, disabled = false, className, ...props }: StepButtonProps) {\n  const hold = useHoldRepeat(onStep, disabled)\n  return (\n    <button\n      type=\"button\"\n      tabIndex={-1}\n      disabled={disabled}\n      data-slot=\"number-input-step\"\n      className={cn(\n        \"relative flex shrink-0 touch-manipulation items-center justify-center select-none [-webkit-touch-callout:none]\",\n        \"transition-colors duration-(--duration-fast) ease-(--ease-mise)\",\n        \"disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5\",\n        className,\n      )}\n      // A mouse press would otherwise pull focus out of the field, and the arrow keys with it.\n      onMouseDown={(event) => event.preventDefault()}\n      onClick={(event) => {\n        if (event.detail === 0) onStep()\n      }}\n      {...hold}\n      {...props}\n    />\n  )\n}\n\nfunction WheelFace({ face, position }: { face: number; position: MotionValue<number> }) {\n  const y = useTransform(position, (value) => `${wheelOffset(face, value) * 100}%`)\n  return (\n    <motion.span className=\"absolute inset-x-0 top-0 text-center\" style={{ y }}>\n      {face}\n    </motion.span>\n  )\n}\n\nfunction RollingDigit({ digit }: { digit: number }) {\n  const reduceMotion = useReducedMotion() ?? false\n  const position = useMotionValue(digit)\n  const target = useRef(digit)\n  useEffect(() => {\n    const delta = shortestDigitDelta(((target.current % 10) + 10) % 10, digit)\n    if (delta === 0) return\n    target.current += delta\n    if (reduceMotion) {\n      position.set(target.current)\n      return\n    }\n    const controls = animate(position, target.current, SPRING_DEFAULT)\n    return () => controls.stop()\n  }, [digit, position, reduceMotion])\n  return (\n    <span data-slot=\"number-input-digit\" className=\"relative inline-block overflow-hidden\">\n      <span className=\"invisible\">0</span>\n      {WHEEL_FACES.map((face) => (\n        <WheelFace key={face} face={face} position={position} />\n      ))}\n    </span>\n  )\n}\n\n/** Sits over the field while nobody is typing, and the field keeps the real text for selection and assistive tech. */\nfunction RollingText({ text, className, style }: { text: string; className?: string; style?: React.CSSProperties }) {\n  const characters = Array.from(text)\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"number-input-roll\"\n      style={style}\n      className={cn(\"pointer-events-none absolute inset-0 flex items-center whitespace-pre tabular-nums\", className)}\n    >\n      {/* Keyed from the right, so the units keep rolling when a thousands column appears on the left. */}\n      {characters.map((character, index) => {\n        const key = characters.length - index\n        return /[0-9]/.test(character) ? (\n          <RollingDigit key={key} digit={Number(character)} />\n        ) : (\n          <span key={key}>{character}</span>\n        )\n      })}\n    </span>\n  )\n}\n\nexport { RollingText, StepButton }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/number-input-state.ts",
      "content": "import type * as React from \"react\"\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\"\nimport { clampNumber, parseNumber, stepValue } from \"@/registry/miseui/ui/number-input-math\"\n\nconst HOLD_DELAY_MS = 400\nconst HOLD_REPEAT_MS = 70\nconst LARGE_STEP_FACTOR = 10\n\ninterface NumberInputStateOptions {\n  value?: number | null\n  defaultValue?: number | null\n  onValueChange?: (value: number | null) => void\n  min?: number\n  max?: number\n  step?: number\n  largeStep?: number\n  locale?: string\n  format?: Intl.NumberFormatOptions\n  readOnly?: boolean\n  disabled?: boolean\n}\n\n/** Starts stepping on press, keeps stepping while held, and stops at a bound because the button goes disabled. */\nfunction useHoldRepeat(onStep: () => void, disabled: boolean) {\n  const timer = useRef<number | undefined>(undefined)\n  const latestStep = useRef(onStep)\n  useLayoutEffect(() => {\n    latestStep.current = onStep\n  })\n  const stop = useCallback(() => {\n    // setTimeout and setInterval share one id pool, so clearTimeout ends whichever phase is running.\n    window.clearTimeout(timer.current)\n    timer.current = undefined\n  }, [])\n  useEffect(() => {\n    if (disabled) stop()\n  }, [disabled, stop])\n  useEffect(() => stop, [stop])\n\n  const onPointerDown = (event: React.PointerEvent) => {\n    if (event.button !== 0) return\n    stop()\n    latestStep.current()\n    timer.current = window.setTimeout(() => {\n      timer.current = window.setInterval(() => latestStep.current(), HOLD_REPEAT_MS)\n    }, HOLD_DELAY_MS)\n  }\n  return { onPointerDown, onPointerUp: stop, onPointerLeave: stop, onPointerCancel: stop }\n}\n\n/** The owner's value, plus what is being typed before it counts. */\nfunction useNumberValue({ value, defaultValue = null, onValueChange, min, max, locale, format }: NumberInputStateOptions) {\n  const bounds = useMemo(() => ({ min, max }), [min, max])\n  const [uncontrolled, setUncontrolled] = useState(defaultValue)\n  const [draft, setDraft] = useState<string | null>(null)\n  const current = value !== undefined ? value : uncontrolled\n  // A held button steps faster than React re-renders a controlled owner, so each tick reads the newest value here.\n  const latest = useRef(current)\n  useLayoutEffect(() => {\n    latest.current = current\n  })\n  const emit = (next: number | null) => {\n    if (next === latest.current) return\n    latest.current = next\n    setUncontrolled(next)\n    onValueChange?.(next)\n  }\n  /** Settles what was typed: blank clears, nonsense goes back to the last good number, the rest is clamped. */\n  const commit = () => {\n    if (draft === null) return latest.current\n    setDraft(null)\n    if (draft.trim() === \"\") {\n      emit(null)\n      return null\n    }\n    const parsed = parseNumber(draft, locale, format?.style === \"percent\")\n    if (parsed === null) return latest.current\n    const next = clampNumber(parsed, bounds)\n    emit(next)\n    return next\n  }\n  const stepFrom = (delta: number) => emit(stepValue(commit(), delta, bounds))\n  return { current, draft, setDraft, emit, commit, stepFrom }\n}\n\nfunction useNumberInputState(options: NumberInputStateOptions) {\n  const { min, max, step = 1, locale, format, readOnly, disabled } = options\n  const largeStep = options.largeStep ?? step * LARGE_STEP_FACTOR\n  const formatter = useMemo(() => new Intl.NumberFormat(locale, format), [locale, format])\n  const { current, draft, setDraft, emit, commit, stepFrom } = useNumberValue(options)\n  const stepBy = (delta: number) => {\n    if (!readOnly && !disabled) stepFrom(delta)\n  }\n  const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    const unit = event.shiftKey ? largeStep : step\n    const moves: Record<string, (() => void) | undefined> = {\n      ArrowUp: () => stepBy(unit),\n      ArrowDown: () => stepBy(-unit),\n      PageUp: () => stepBy(largeStep),\n      PageDown: () => stepBy(-largeStep),\n      // Without a bound these keys keep their normal job of moving the caret.\n      Home: min === undefined ? undefined : () => emit(min),\n      End: max === undefined ? undefined : () => emit(max),\n      Enter: commit,\n    }\n    const move = moves[event.key]\n    if (!move || readOnly || disabled) return\n    // Enter must stay free to submit the surrounding form.\n    if (event.key !== \"Enter\") event.preventDefault()\n    move()\n  }\n  const formatted = current === null ? \"\" : formatter.format(current)\n  return {\n    current,\n    draft,\n    formatted,\n    text: draft ?? formatted,\n    atMin: current !== null && min !== undefined && current <= min,\n    atMax: current !== null && max !== undefined && current >= max,\n    increment: () => stepBy(step),\n    decrement: () => stepBy(-step),\n    onChange: (event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value),\n    onBlur: commit,\n    onKeyDown,\n  }\n}\n\ntype NumberInputState = ReturnType<typeof useNumberInputState>\n\nexport { useHoldRepeat, useNumberInputState, type NumberInputState, type NumberInputStateOptions }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/number-input-math.ts",
      "content": "export interface NumberBounds {\n  min?: number\n  max?: number\n}\n\n/** Decimal places in a number, including the ones written as 1e-7. */\nfunction decimalsOf(value: number) {\n  const [mantissa, exponent] = String(value).split(\"e\")\n  const fraction = mantissa.split(\".\")[1]?.length ?? 0\n  return Math.max(0, fraction - Number(exponent ?? 0))\n}\n\nfunction clampNumber(value: number, { min, max }: NumberBounds) {\n  const raised = min === undefined ? value : Math.max(min, value)\n  return max === undefined ? raised : Math.min(max, raised)\n}\n\n/** Rounds to the step's own precision, because 0.1 + 0.2 must show 0.3 and not 0.30000000000000004. */\nfunction stepValue(current: number | null, delta: number, bounds: NumberBounds) {\n  if (current === null) return clampNumber(0, bounds)\n  const precision = Math.max(decimalsOf(current), decimalsOf(delta))\n  return clampNumber(Number((current + delta).toFixed(precision)), bounds)\n}\n\nfunction decimalSeparator(locale?: string) {\n  const parts = new Intl.NumberFormat(locale).formatToParts(1.5)\n  return parts.find((part) => part.type === \"decimal\")?.value ?? \".\"\n}\n\n/**\n * Reads what people type, formatted or not: \"1,234.5\", \"1.234,5\" in German, \"$12\", \"12%\".\n * Everything except digits, the sign and the locale's decimal mark is noise. Null means it was not a number.\n */\nfunction parseNumber(text: string, locale?: string, percent = false) {\n  const decimal = decimalSeparator(locale)\n  const kept = Array.from(text)\n    .filter((char) => /[0-9-]/.test(char) || char === decimal)\n    .join(\"\")\n    .replace(decimal, \".\")\n  if (kept === \"\" || kept === \"-\" || kept === \".\") return null\n  const parsed = Number(kept)\n  if (Number.isNaN(parsed)) return null\n  // Intl multiplies percent by 100 when formatting, so typing 12 has to mean 0.12.\n  return percent ? Number((parsed / 100).toFixed(decimalsOf(parsed) + 2)) : parsed\n}\n\n/** The way round the odometer is shorter: 9 to 0 is one step forward, not nine back. */\nfunction shortestDigitDelta(from: number, to: number) {\n  const forward = ((to - from) % 10 + 10) % 10\n  return forward > 5 ? forward - 10 : forward\n}\n\n/** Where digit `face` sits on a wheel turned to `position`, in cell heights from the window. Wraps so the wheel has no seam. */\nfunction wheelOffset(face: number, position: number) {\n  return (((face - position) % 10) + 15) % 10 - 5\n}\n\n/** The widest a character in this type gets, as a share of its size. Tabular digits sit just under it. */\nconst CHARACTER_WIDTH_EM = 0.62\n\n/** Three rem at most, less when the text would not fit: needs a container around the field for `cqi` to measure. */\nfunction fittedFontSize(length: number) {\n  const share = (Math.max(length, 1) * CHARACTER_WIDTH_EM).toFixed(2).replace(/0$/, \"\")\n  return `min(3rem, calc(100cqi / ${share}))`\n}\n\nexport { clampNumber, fittedFontSize, parseNumber, shortestDigitDelta, stepValue, wheelOffset }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}