NumberInput.tsx 2.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
  1. import { useState, type InputHTMLAttributes } from 'react';
  2. type NativeInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'value' | 'onChange' | 'min' | 'max'>;
  3. interface NumberInputProps extends NativeInputProps {
  4. value: number | null | undefined;
  5. onChange: (value: number) => void;
  6. min?: number;
  7. max?: number;
  8. /** Parse with parseInt (default); pass false for fields with a fractional step. */
  9. integer?: boolean;
  10. /** Committed when the field is left empty. Without it an empty field reverts to `value`. */
  11. fallback?: number;
  12. }
  13. /**
  14. * A number field that can be cleared and retyped (#3182).
  15. *
  16. * Clamping inside onChange rewrites the field on every keystroke: clearing a
  17. * "1" snaps straight back to 1, and typing the "6" of 60 into a field with a
  18. * minimum of 45 turns it into 45. This keeps what was typed as a draft while
  19. * the field is being edited, passes a value up only once the draft is a number
  20. * inside [min, max], and settles the field on blur — clamped into range, or
  21. * `fallback` when left empty.
  22. */
  23. export function NumberInput({
  24. value,
  25. onChange,
  26. min,
  27. max,
  28. integer = true,
  29. fallback,
  30. onBlur,
  31. ...rest
  32. }: NumberInputProps) {
  33. const [draft, setDraft] = useState<string | null>(null);
  34. const parse = (raw: string) => (integer ? parseInt(raw, 10) : parseFloat(raw));
  35. const inRange = (n: number) => (min === undefined || n >= min) && (max === undefined || n <= max);
  36. const clamp = (n: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n));
  37. return (
  38. <input
  39. {...rest}
  40. type="number"
  41. min={min}
  42. max={max}
  43. value={draft ?? value ?? ''}
  44. onChange={(e) => {
  45. const raw = e.target.value;
  46. setDraft(raw);
  47. const n = parse(raw);
  48. if (!Number.isNaN(n) && inRange(n) && n !== value) onChange(n);
  49. }}
  50. onBlur={(e) => {
  51. if (draft !== null) {
  52. const n = parse(draft);
  53. const settled = Number.isNaN(n) ? fallback : n;
  54. if (settled !== undefined && clamp(settled) !== value) onChange(clamp(settled));
  55. setDraft(null);
  56. }
  57. onBlur?.(e);
  58. }}
  59. />
  60. );
  61. }