import { useState, type InputHTMLAttributes } from 'react'; type NativeInputProps = Omit, 'type' | 'value' | 'onChange' | 'min' | 'max'>; interface NumberInputProps extends NativeInputProps { value: number | null | undefined; onChange: (value: number) => void; min?: number; max?: number; /** Parse with parseInt (default); pass false for fields with a fractional step. */ integer?: boolean; /** Committed when the field is left empty. Without it an empty field reverts to `value`. */ fallback?: number; } /** * A number field that can be cleared and retyped (#3182). * * Clamping inside onChange rewrites the field on every keystroke: clearing a * "1" snaps straight back to 1, and typing the "6" of 60 into a field with a * minimum of 45 turns it into 45. This keeps what was typed as a draft while * the field is being edited, passes a value up only once the draft is a number * inside [min, max], and settles the field on blur — clamped into range, or * `fallback` when left empty. */ export function NumberInput({ value, onChange, min, max, integer = true, fallback, onBlur, ...rest }: NumberInputProps) { const [draft, setDraft] = useState(null); const parse = (raw: string) => (integer ? parseInt(raw, 10) : parseFloat(raw)); const inRange = (n: number) => (min === undefined || n >= min) && (max === undefined || n <= max); const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n)); return ( { const raw = e.target.value; setDraft(raw); const n = parse(raw); if (!Number.isNaN(n) && inRange(n) && n !== value) onChange(n); }} onBlur={(e) => { if (draft !== null) { const n = parse(draft); const settled = Number.isNaN(n) ? fallback : n; if (settled !== undefined && clamp(settled) !== value) onChange(clamp(settled)); setDraft(null); } onBlur?.(e); }} /> ); }