'use client'; import * as React from 'react'; import { ChevronDownIcon, ChevronUpIcon } from '@/icons'; import { cn } from '@/lib/utils'; import { disabledField } from '@/lib/cva-presets'; import { useControllableState } from '@/hooks/use-controllable-state'; export interface InputNumberProps extends Omit, 'value' | 'defaultValue' | 'onChange' | 'step'> { value?: number | null; defaultValue?: number | null; /** Fires with the parsed value, or `null` once the field is cleared. */ onChange?: (value: number | null) => void; min?: number; max?: number; step?: number; /** Decimal places to round to on commit. Defaults to whatever `step` implies. */ precision?: number; /** Renders the value for display — currency symbols, units, thousands separators. */ formatter?: (value: number | null) => string; /** Turns a typed string back into a number. Required whenever `formatter` is set. */ parser?: (displayValue: string) => number; /** Show the stepper column. */ controls?: boolean; /** Let ArrowUp/ArrowDown step the value. */ keyboard?: boolean; classNames?: { input?: string; controls?: string }; } /** Decimal places a number is written with — used to keep stepping exact. */ const decimalsOf = (value: number) => { const text = String(value); const point = text.indexOf('.'); return point === -1 ? 0 : text.length - point - 1; }; /** * Round to `decimals` places the way the decimal number reads, not the way its * binary approximation does. * * `(1.005).toFixed(2)` is `"1.00"`, because 1.005 is stored as 1.00499…. Moving * the point with exponent notation instead re-parses from the decimal string, * so 1.005 → 100.5 → 101 → 1.01, which is what someone typing it expects. */ const roundTo = (value: number, decimals: number) => { const [mantissa, exponent = '0'] = `${value}e`.split('e'); const shifted = Math.round(Number(`${mantissa}e${Number(exponent) + decimals}`)); const [rounded, roundedExponent = '0'] = `${shifted}e`.split('e'); return Number(`${rounded}e${Number(roundedExponent) - decimals}`); }; /** * Numeric input with stepper controls. * * A plain `` has no formatting hook, silently accepts `e` * and `+`, and steps inconsistently across browsers. This is a text input * carrying `role="spinbutton"`, so the value is announced with its bounds while * `formatter`/`parser` stay free to show currency or units. * * The value is only clamped on commit — blur or a step — never mid-keystroke, * because clamping while someone types "50" into a field with `min={10}` would * fight them at "5". * * ```tsx * * * `$ ${v ?? ''}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} * parser={(v) => Number(v.replace(/\$\s?|,/g, ''))} * /> * ``` */ function InputNumber({ value, defaultValue, onChange, min = -Infinity, max = Infinity, step = 1, precision, formatter, parser, controls = true, keyboard = true, disabled, readOnly, className, classNames, onKeyDown, onBlur, onFocus, ...props }: InputNumberProps) { /* No `onChange` on the hook: this one fires only when the number actually moved, which is the check in `emit` below. */ const [current, setCurrent] = useControllableState({ value, defaultValue: defaultValue ?? null, }); const toDisplay = (next: number | null) => { if (formatter) return formatter(next); if (next === null || Number.isNaN(next)) return ''; return precision === undefined ? String(next) : next.toFixed(precision); }; /** * Text the user is part-way through typing, or `null` when there is none. * * The displayed text is *derived* from the value rather than mirrored into * state, so there is nothing to keep in sync: a controlled parent that * declines a change simply re-renders its own value, and the field follows. * Only a live edit — where "1." and "-" are legal keystrokes but not numbers — * needs text of its own. */ const [draft, setDraft] = React.useState(null); const display = draft ?? toDisplay(current); const clamp = (next: number) => Math.min(Math.max(next, min), max); /** * `precision` is an instruction to round; without it the only job is to strip * the noise arithmetic introduces. Taking the wider of the step's and the * value's own decimals is what keeps `1.5` steppable by `1` — rounding to the * step's zero decimals would quietly turn 2.5 into 3. */ const round = (next: number, base: number) => roundTo(next, precision ?? Math.max(decimalsOf(step), decimalsOf(base))); const emit = (next: number | null) => { setCurrent(next); if (next !== current) onChange?.(next); }; const parse = (text: string) => (parser ? parser(text) : Number(text)); const handleChange = (event: React.ChangeEvent) => { const text = event.target.value; setDraft(text); if (text.trim() === '') { emit(null); return; } const parsed = parse(text); /* A half-typed "-" or "1." parses to NaN. Keep the text, emit nothing — the value stays whatever it last was until the entry makes sense. */ if (!Number.isNaN(parsed)) emit(parsed); }; const stepBy = (direction: 1 | -1) => { if (disabled || readOnly) return; const base = current ?? 0; const next = clamp(round(base + direction * step, base)); emit(next); /* Hand the display back to the value. In controlled mode that means a parent which ignores the change leaves the field showing what it still holds, rather than the number we proposed. */ setDraft(null); }; const handleKeyDown = (event: React.KeyboardEvent) => { onKeyDown?.(event); if (!keyboard || event.defaultPrevented) return; if (event.key === 'ArrowUp') { event.preventDefault(); stepBy(1); } else if (event.key === 'ArrowDown') { event.preventDefault(); stepBy(-1); } }; const handleBlur = (event: React.FocusEvent) => { const text = display.trim(); if (text === '') { emit(null); } else { const parsed = parse(text); /* An unparseable entry is discarded rather than committed as NaN; the field falls back to whatever the value still is. */ if (!Number.isNaN(parsed)) emit(clamp(round(parsed, parsed))); } /* Editing is over — the display goes back to being derived. */ setDraft(null); onBlur?.(event); }; const atMax = current !== null && current >= max; const atMin = current !== null && current <= min; return (
{controls ? (
{( [ ['increment', 'Increase', 1, atMax, ChevronUpIcon], ['decrement', 'Decrease', -1, atMin, ChevronDownIcon], ] as const ).map(([slot, label, direction, atBound, StepIcon]) => ( ))}
) : null}
); } export { InputNumber };