{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"number-input","type":"registry:ui","title":"NumberInput","description":"Numeric field with decrement/increment buttons that clamp to min/max, disable themselves at the bounds, and step fractional values without float drift","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["forms"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/number-input/NumberInput.tsx","content":"\"use client\";\n\nimport { forwardRef, useEffect, useRef, useState } from \"react\";\nimport type { ChangeEvent, ComponentPropsWithoutRef, FocusEvent, KeyboardEvent } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useField } from \"../../internals/field\";\nimport { mergeIds } from \"../../internals/merge-ids\";\nimport { DURATIONS } from \"../../internals/motion/tokens\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./number-input.css\";\n\nexport interface NumberInputOwnProps {\n\t/**\n\t * Current value; `null` when the field is empty. Controlled when supplied:\n\t * pair it with `onValueChange`, the React counterpart of the Svelte\n\t * source's `bind:value`.\n\t */\n\tvalue?: number | null;\n\t/** Initial value when uncontrolled (no `value` prop given). `null` for an empty field. */\n\tdefaultValue?: number | null;\n\t/** Called with the new value on every change, including a clear to `null`. */\n\tonValueChange?: (value: number | null) => void;\n\t/** Lower bound. Leave unset for no lower bound. */\n\tmin?: number;\n\t/** Upper bound. Leave unset for no upper bound. */\n\tmax?: number;\n\t/** Increment size, including fractional steps. */\n\tstep?: number;\n\t/** Blocks focus, typing and the step buttons; excluded from form submission. Overridden by a surrounding FormField. */\n\tdisabled?: boolean;\n\t/** Blocks typing and the step buttons but stays focusable and is still submitted, unlike `disabled`. */\n\treadonly?: boolean;\n\t/** Native `required`. Overridden by a surrounding FormField. */\n\trequired?: boolean;\n\t/** Drives the error border and `aria-invalid`. Overridden by a surrounding FormField. */\n\tinvalid?: boolean;\n\t/** Element id. Overridden by a surrounding FormField's own `controlId`. */\n\tid?: string;\n\t/** Native `name`, read on form submission. */\n\tname?: string;\n\t/** Accessible name — for a control with no visible Label next to it. */\n\tlabel?: string;\n\t/** Additional CSS classes, applied to the outer bordered wrapper. */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off\n\t * by default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\n/**\n * Own props plus every native `<input>` attribute and listener; the rest are\n * spread onto the inner `<input>` (see the component README for the rules).\n */\nexport interface NumberInputProps\n\textends\n\t\tOmit<\n\t\t\tComponentPropsWithoutRef<\"input\">,\n\t\t\tkeyof NumberInputOwnProps | \"type\" | \"value\" | \"defaultValue\"\n\t\t>,\n\t\tNumberInputOwnProps {}\n\nexport const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(\n\t(\n\t\t{\n\t\t\tvalue: valueProp,\n\t\t\tdefaultValue = null,\n\t\t\tonValueChange,\n\t\t\tmin,\n\t\t\tmax,\n\t\t\tstep = 1,\n\t\t\tdisabled = false,\n\t\t\treadonly = false,\n\t\t\trequired = false,\n\t\t\tinvalid = false,\n\t\t\tid,\n\t\t\tname,\n\t\t\tlabel,\n\t\t\tclassName,\n\t\t\tsound = false,\n\t\t\tinputMode = \"decimal\",\n\t\t\tonKeyDown,\n\t\t\tonFocus,\n\t\t\tonBlur,\n\t\t\tonChange,\n\t\t\t\"aria-describedby\": consumerDescribedBy,\n\t\t\t\"aria-label\": consumerAriaLabel,\n\t\t\t\"aria-invalid\": consumerAriaInvalid,\n\t\t\treadOnly: readOnlyAttr,\n\t\t\t// An `<input>` is a void element: a stray `children` must not reach it.\n\t\t\tchildren: _children,\n\t\t\t...rest\n\t\t},\n\t\tref\n\t) => {\n\t\t// The Svelte side's `value = $bindable(null)` becomes the standard\n\t\t// controlled/uncontrolled split: uncontrolled by default, controlled\n\t\t// the moment a `value` prop is passed (`null` counts — it is a real\n\t\t// value meaning \"empty\", not an absence).\n\t\tconst isControlled = valueProp !== undefined;\n\t\tconst [uncontrolledValue, setUncontrolledValue] = useState<number | null>(defaultValue);\n\t\tconst value = isControlled ? valueProp : uncontrolledValue;\n\n\t\t// The single internal write site for `value`. In controlled mode the\n\t\t// parent owns the value and hears about the change through\n\t\t// `onValueChange` instead.\n\t\tfunction setValue(next: number | null) {\n\t\t\tif (!isControlled) setUncontrolledValue(next);\n\t\t}\n\n\t\t// `type=\"number\"`'s own value-sanitization silently reports \"\" for ANY\n\t\t// syntactically incomplete number — \"9.\", \"-\", \"1e\" all read back as the\n\t\t// empty string, indistinguishable from a genuinely cleared field. That\n\t\t// collapsed \"the user is mid-typing a decimal\" into \"the user cleared\n\t\t// it\", nulling the bound value the instant a \".\" landed. A plain text\n\t\t// input sidesteps the sanitization entirely: `rawText` always holds\n\t\t// exactly what was typed, and `value` (the parsed number) only updates\n\t\t// once that text is a complete, unambiguous number — never on an\n\t\t// incomplete intermediate.\n\t\tconst [rawText, setRawText] = useState(value === null ? \"\" : String(value));\n\n\t\t// Never drives a render, so a ref: it is read only by the resync guard\n\t\t// below and written by the focus/blur handlers.\n\t\tconst isFocused = useRef(false);\n\n\t\t// Keeps the display in sync with `value` for a change that did not\n\t\t// originate from typing here — a controlled parent passing a new value,\n\t\t// the initial mount — but never while focused: resyncing mid-keystroke\n\t\t// is exactly what would turn \"9.\" back into \"9\" (or \"1.20\" back into\n\t\t// \"1.2\") before the rest of what the user is typing ever lands. The\n\t\t// step buttons and the keyboard handler below write `rawText`\n\t\t// themselves directly for the same reason they write `value` directly,\n\t\t// so this effect re-running after them is a harmless same-string no-op,\n\t\t// not the only path they rely on.\n\t\tuseEffect(() => {\n\t\t\tif (isFocused.current) return;\n\t\t\tsetRawText(value === null ? \"\" : String(value));\n\t\t}, [value]);\n\n\t\t// Undefined outside a FormField — every derived below then falls back to\n\t\t// this component's own props instead of the context, so the control works\n\t\t// standalone exactly as it does wrapped.\n\t\tconst field = useField();\n\n\t\tconst effectiveId = field?.controlId ?? id;\n\t\tconst effectiveDisabled = field?.disabled ?? disabled;\n\t\tconst effectiveRequired = field?.required ?? required;\n\t\tconst effectiveInvalid = field?.invalid ?? invalid;\n\t\tconst isReadonly = readonly || !!readOnlyAttr;\n\t\tconst describedBy = mergeIds(field?.describedBy, consumerDescribedBy);\n\n\t\tconst playCue = useSoundCue(sound);\n\n\t\t// How many decimal digits a number carries, e.g. 2 for 0.25.\n\t\tfunction decimalPlaces(n: number): number {\n\t\t\tconst s = String(n);\n\t\t\tconst dot = s.indexOf(\".\");\n\t\t\treturn dot === -1 ? 0 : s.length - dot - 1;\n\t\t}\n\n\t\t// The step grid is anchored at `min` (or 0 with none set), not at\n\t\t// `step`'s own decimal count alone — rounding to `step` alone breaks the\n\t\t// moment the grid's arithmetic needs more precision than `step` carries.\n\t\t// With `min={0.25} step={0.5}`, 0.25 + 0.5 is exactly 0.75; rounding that\n\t\t// to step's one decimal place would corrupt an already-exact result to\n\t\t// 0.8.\n\t\tfunction gridPrecision(): number {\n\t\t\treturn Math.max(decimalPlaces(step), decimalPlaces(min ?? 0));\n\t\t}\n\n\t\t// Rounds to the grid's precision after every add, so ten 0.1 increments\n\t\t// land on exactly 1 instead of drifting to something like\n\t\t// 0.9999999999999999 the way plain float addition would.\n\t\tfunction roundToStep(n: number): number {\n\t\t\tconst factor = 10 ** gridPrecision();\n\t\t\treturn Math.round(n * factor) / factor;\n\t\t}\n\n\t\tfunction clampValue(n: number): number {\n\t\t\tlet result = n;\n\t\t\tif (min !== undefined) result = Math.max(min, result);\n\t\t\tif (max !== undefined) result = Math.min(max, result);\n\t\t\treturn result;\n\t\t}\n\n\t\t// Mirrors the native stepUp()/stepDown() behaviour for an empty field:\n\t\t// the first step lands exactly on the minimum (or 0 with none set),\n\t\t// never on min + step and never on NaN. A step that lands past a bound\n\t\t// clamps there, and every step after that continues arithmetic from the\n\t\t// clamped value rather than re-snapping to the nearest point on the\n\t\t// original grid — matching how a native number input's own\n\t\t// stepUp()/stepDown() keeps going from wherever clamping left it. E.g.\n\t\t// with min=0 max=10 step=3: 9 → 10 (clamped) → 7 → 4 → 1, not back to\n\t\t// the 0/3/6/9 grid.\n\t\tfunction nextStepValue(direction: 1 | -1): number {\n\t\t\tif (value === null) return clampValue(min ?? 0);\n\t\t\treturn clampValue(roundToStep(value + direction * step));\n\t\t}\n\n\t\t// A control at its bound can still receive a synthetic click that walks\n\t\t// straight past the native `disabled` attribute on the button, so these\n\t\t// are read by the click/keydown handlers below rather than trusted to\n\t\t// the attribute alone — and reused to actually disable the buttons, so\n\t\t// the bound is communicated visually and not just enforced silently.\n\t\tconst decrementDisabled =\n\t\t\teffectiveDisabled || isReadonly || (value !== null && min !== undefined && value <= min);\n\t\tconst incrementDisabled =\n\t\t\teffectiveDisabled || isReadonly || (value !== null && max !== undefined && value >= max);\n\n\t\t// Which stepper just fired, for `DURATIONS.micro` (80ms) afterwards, or\n\t\t// `null` between steps. A pointer gets its press feedback from `:active`\n\t\t// for free; a keyboard press has no `:active` to give, so this flag hands\n\t\t// the very same rule to the arrow keys. One shared visual answer to \"that\n\t\t// stepper just fired\", whichever device fired it — no second keyframe, and\n\t\t// so no animation-restart problem on a held key.\n\t\t//\n\t\t// Nothing here touches the `<input>`: a transform on the field would blur\n\t\t// the digits the reader is checking and fight the caret.\n\t\tconst [steppingDirection, setSteppingDirection] = useState<1 | -1 | null>(null);\n\t\tconst steppingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n\t\t// Re-armed, never stacked: a held ArrowUp repeats faster than 80ms, and a\n\t\t// second timer on top of the first would clear the flag mid-repeat.\n\t\tfunction flagStepping(direction: 1 | -1) {\n\t\t\tsetSteppingDirection(direction);\n\t\t\tif (steppingTimer.current !== null) clearTimeout(steppingTimer.current);\n\t\t\tsteppingTimer.current = setTimeout(() => {\n\t\t\t\tsteppingTimer.current = null;\n\t\t\t\tsetSteppingDirection(null);\n\t\t\t}, DURATIONS.micro);\n\t\t}\n\n\t\tuseEffect(() => {\n\t\t\treturn () => {\n\t\t\t\tif (steppingTimer.current !== null) clearTimeout(steppingTimer.current);\n\t\t\t};\n\t\t}, []);\n\n\t\t// Shared by the buttons and the keyboard handler below, so button-driven\n\t\t// and arrow-key-driven stepping can never drift apart in rounding — and,\n\t\t// since `flagStepping` is called from here and from nowhere else, so that\n\t\t// the feedback can only ever fire on an actual step. Typing goes through\n\t\t// `handleInput`, which cannot reach it: a field that flashed a stepper on\n\t\t// every keystroke would be reporting something that did not happen.\n\t\tfunction applyStep(direction: 1 | -1) {\n\t\t\tconst next = nextStepValue(direction);\n\t\t\tsetValue(next);\n\t\t\tsetRawText(String(next));\n\t\t\tplayCue(\"tick\");\n\t\t\tonValueChange?.(next);\n\t\t\tflagStepping(direction);\n\t\t}\n\n\t\tfunction handleDecrement() {\n\t\t\tif (decrementDisabled) return;\n\t\t\tapplyStep(-1);\n\t\t}\n\n\t\tfunction handleIncrement() {\n\t\t\tif (incrementDisabled) return;\n\t\t\tapplyStep(1);\n\t\t}\n\n\t\t// Arrow keys step a native `type=\"number\"` input for free; a plain text\n\t\t// input has no such native behaviour, so it is reimplemented here,\n\t\t// routed through the same `nextStepValue` the buttons use rather than a\n\t\t// second copy of the rounding/clamping logic.\n\t\tfunction handleKeydown(event: KeyboardEvent<HTMLInputElement>) {\n\t\t\tif (!effectiveDisabled && !isReadonly) handleOwnKey(event);\n\t\t\tonKeyDown?.(event);\n\t\t}\n\n\t\tfunction handleOwnKey(event: KeyboardEvent<HTMLInputElement>) {\n\t\t\tif (event.key === \"ArrowUp\") {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tif (incrementDisabled) return;\n\t\t\t\tapplyStep(1);\n\t\t\t} else if (event.key === \"ArrowDown\") {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tif (decrementDisabled) return;\n\t\t\t\tapplyStep(-1);\n\t\t\t}\n\t\t}\n\n\t\tfunction handleInput(event: ChangeEvent<HTMLInputElement>) {\n\t\t\tif (effectiveDisabled || isReadonly) return;\n\t\t\tapplyTyped(event.currentTarget.value);\n\t\t\tonChange?.(event);\n\t\t}\n\n\t\tfunction applyTyped(raw: string) {\n\t\t\tsetRawText(raw);\n\n\t\t\tif (raw === \"\") {\n\t\t\t\tif (value !== null) {\n\t\t\t\t\tsetValue(null);\n\t\t\t\t\tonValueChange?.(null);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst parsed = Number(raw);\n\t\t\t// A syntactically incomplete number — \"9.\", \"-\", \"1e\", \".\" — parses\n\t\t\t// to NaN here. Leave `value` untouched and let the user keep typing;\n\t\t\t// `rawText` above already shows exactly what they typed.\n\t\t\tif (Number.isNaN(parsed)) return;\n\n\t\t\tsetValue(parsed);\n\t\t\tonValueChange?.(parsed);\n\t\t}\n\n\t\tfunction handleFocus(event: FocusEvent<HTMLInputElement>) {\n\t\t\tisFocused.current = true;\n\t\t\tonFocus?.(event);\n\t\t}\n\n\t\t// Clamping happens here, not per keystroke — that would make typing \"50\"\n\t\t// past a lower max impossible to type through — and this is also where\n\t\t// the display is canonicalised: a trailing \".\" or a lone \"-\" that never\n\t\t// resolved into a full number is dropped once typing is done.\n\t\tfunction handleBlur(event: FocusEvent<HTMLInputElement>) {\n\t\t\tisFocused.current = false;\n\t\t\tlet settled = value;\n\t\t\tif (settled !== null) {\n\t\t\t\tconst clamped = clampValue(settled);\n\t\t\t\tif (clamped !== settled) {\n\t\t\t\t\tsettled = clamped;\n\t\t\t\t\tsetValue(clamped);\n\t\t\t\t\tonValueChange?.(clamped);\n\t\t\t\t}\n\t\t\t}\n\t\t\tsetRawText(settled === null ? \"\" : String(settled));\n\t\t\tonBlur?.(event);\n\t\t}\n\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"ft-number-input border-input inline-flex w-fit items-stretch overflow-hidden rounded-[8px] border\",\n\t\t\t\t\teffectiveInvalid && \"border-destructive/50\",\n\t\t\t\t\tclassName\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{/*\n\t\t\t\t\t`ft-number-input-step` exists so the colocated stylesheet can reach\n\t\t\t\t\tthese two buttons at all — they carried no `ft-*` class before. It\n\t\t\t\t\talso replaces `transition-colors`, which an unlayered `transition`\n\t\t\t\t\tshorthand on the same element would have overridden silently; see\n\t\t\t\t\tthe stylesheet for the colour channel written back by hand.\n\t\t\t\t*/}\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName=\"ft-number-input-step text-muted-foreground border-input hover:bg-accent hover:text-accent-foreground flex w-[34px] shrink-0 cursor-pointer items-center justify-center border-r focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\taria-label=\"Decrease value\"\n\t\t\t\t\tdisabled={decrementDisabled}\n\t\t\t\t\tdata-stepping={steppingDirection === -1 ? \"true\" : undefined}\n\t\t\t\t\tonClick={handleDecrement}\n\t\t\t\t>\n\t\t\t\t\t−\n\t\t\t\t</button>\n\t\t\t\t<input\n\t\t\t\t\t{...rest}\n\t\t\t\t\tref={ref}\n\t\t\t\t\ttype=\"text\"\n\t\t\t\t\tinputMode={inputMode}\n\t\t\t\t\tclassName=\"ft-number-input-field text-foreground w-full min-w-[3ch] border-0 bg-transparent px-[18px] py-[9px] text-center font-mono text-[13px] focus-visible:outline-none disabled:cursor-not-allowed\"\n\t\t\t\t\tid={effectiveId}\n\t\t\t\t\tname={name}\n\t\t\t\t\tvalue={rawText}\n\t\t\t\t\tdisabled={effectiveDisabled}\n\t\t\t\t\treadOnly={isReadonly}\n\t\t\t\t\trequired={effectiveRequired}\n\t\t\t\t\taria-invalid={effectiveInvalid ? \"true\" : consumerAriaInvalid}\n\t\t\t\t\taria-describedby={describedBy}\n\t\t\t\t\taria-label={label ?? consumerAriaLabel}\n\t\t\t\t\tonChange={handleInput}\n\t\t\t\t\tonKeyDown={handleKeydown}\n\t\t\t\t\tonFocus={handleFocus}\n\t\t\t\t\tonBlur={handleBlur}\n\t\t\t\t/>\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName=\"ft-number-input-step text-muted-foreground border-input hover:bg-accent hover:text-accent-foreground flex w-[34px] shrink-0 cursor-pointer items-center justify-center border-l focus-visible:outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50\"\n\t\t\t\t\taria-label=\"Increase value\"\n\t\t\t\t\tdisabled={incrementDisabled}\n\t\t\t\t\tdata-stepping={steppingDirection === 1 ? \"true\" : undefined}\n\t\t\t\t\tonClick={handleIncrement}\n\t\t\t\t>\n\t\t\t\t\t+\n\t\t\t\t</button>\n\t\t\t</div>\n\t\t);\n\t}\n);\n\nNumberInput.displayName = \"NumberInput\";\n","type":"registry:ui","target":"@ui/fancy/components/number-input/NumberInput.tsx"},{"path":"react/src/components/number-input/index.ts","content":"\"use client\";\n\nexport { NumberInput } from \"./NumberInput\";\nexport type { NumberInputProps } from \"./NumberInput\";\n","type":"registry:ui","target":"@ui/fancy/components/number-input/index.ts"},{"path":"react/src/components/number-input/number-input.css","content":".ft-number-input-field:focus-visible {\n\t--ft-number-input-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n\tbox-shadow: inset 0 0 0 1px var(--ft-number-input-accent);\n}\n\n.ft-number-input-step {\n\t/* One local alias so the token pair is typed once rather than seven times.\n\t   150ms = tokens.DURATIONS.fast, cubic-bezier(0.4, 0, 0.2, 1) = tokens.EASINGS.inout */\n\t--ft-number-input-motion: var(--ft-duration-fast, 150ms)\n\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));\n\t/* Replaces the `transition-colors` utility removed from the class string\n\t   above. Colour is a state change, not motion, so it stays outside the\n\t   reduced-motion query. `text-decoration-color`, `fill` and `stroke`\n\t   never change on these two buttons, so the three that do are the\n\t   faithful subset of what the utility covered. */\n\ttransition:\n\t\tcolor var(--ft-number-input-motion),\n\t\tbackground-color var(--ft-number-input-motion),\n\t\tborder-color var(--ft-number-input-motion);\n\t/* Kills the ~300ms tap delay without blocking scroll — the same rule,\n\t   for the same reason, as `.ft-pressable`. Stepping a value is exactly\n\t   the interaction a finger repeats, so the delay is felt here. */\n\ttouch-action: manipulation;\n}\n\n/*\n * Universal fallback — a press must be acknowledged under reduced motion\n * too, and a UA that supports neither query still needs some affordance.\n * `:not(:disabled)` is redundant against `disabled:pointer-events-none`\n * and deliberately kept: a bound-reached stepper must read as inert, and\n * that should not depend on a utility staying in the class string.\n */\n.ft-number-input-step:active:not(:disabled),\n.ft-number-input-step[data-stepping=\"true\"]:not(:disabled) {\n\topacity: var(--ft-number-input-press-opacity, 0.85);\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-number-input-step {\n\t\t/* The individual `scale` property, not `transform: scale()`: this\n\t\t   rule is unlayered, so a `transform` here would beat any\n\t\t   transform utility a consumer passes through the public `class`\n\t\t   prop — a `rotate-45` would silently vanish, at rest AND under\n\t\t   the press. `scale` composes with the consumer's `transform`\n\t\t   instead of replacing it. */\n\t\tscale: 1;\n\t\ttransition:\n\t\t\tcolor var(--ft-number-input-motion),\n\t\t\tbackground-color var(--ft-number-input-motion),\n\t\t\tborder-color var(--ft-number-input-motion),\n\t\t\tscale var(--ft-number-input-motion);\n\t}\n\n\t.ft-number-input-step:active:not(:disabled),\n\t.ft-number-input-step[data-stepping=\"true\"]:not(:disabled) {\n\t\tscale: var(--ft-number-input-press-scale, 0.97);\n\t\t/* Full motion = scale only; reduced motion = opacity only. Never both. */\n\t\topacity: 1;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/number-input/number-input.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/number-input?framework=react"}}