fix(NumberInput): reject non-numeric characters on input#3761
fix(NumberInput): reject non-numeric characters on input#3761
Conversation
✅ No New Circular DependenciesNo new circular dependencies detected. Current count: 0 |
📦 Alpha Package Version PublishedUse Use |
🔍 Visual review for your branch is published 🔍Here are the links to: |
Coverage Report for packages/react
File Coverage
|
||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Pull request overview
Adds proactive character filtering to the experimental NumberInput so invalid characters are blocked before they reach InputField’s internal localValue, reducing visual desync between displayed text and emitted numeric values.
Changes:
- Add an
onBeforeInputhandler to block non-numeric inserts based on simulated post-insert value. - Refactor
handleChangeto reuseformattedValuefromextractNumberand renamefinalValue→clampedValue. - Format clamped values using
Intl.NumberFormatinstead of re-parsing viaextractNumber.
| const clampedValue = Math.max( | ||
| min ?? -Infinity, | ||
| Math.min(max ?? Infinity, parsedValue) | ||
| ) | ||
|
|
||
| const finalExtractedData = extractNumber(finalValue.toString(), { | ||
| maxDecimals, | ||
| }) | ||
| setFieldValue(finalExtractedData?.formattedValue ?? "") | ||
| if (clampedValue !== parsedValue) { | ||
| setFieldValue(formatValue(clampedValue, locale, maxDecimals)) | ||
| } else { | ||
| setFieldValue(formattedValue) | ||
| } | ||
|
|
||
| onChange?.(finalExtractedData?.value ?? null) | ||
| onChange?.(clampedValue) | ||
| } |
There was a problem hiding this comment.
When clamping applies, the display string is produced via Intl.NumberFormat (which rounds and uses default fraction digits when maxDecimals is undefined), but the emitted value is the raw clampedValue. This can desync what the user sees vs what onChange receives (e.g. max=1.235, maxDecimals=2 → UI shows 1.24 while onChange emits 1.235). Consider normalizing clampedValue through the same maxDecimals logic used for user input (and deriving both formatted + numeric values from that single normalization) before calling setFieldValue/onChange.
| const handleBeforeInput = (e: React.FormEvent<HTMLInputElement>) => { | ||
| const { data } = e.nativeEvent as InputEvent | ||
| if (!data) return | ||
|
|
||
| const input = e.currentTarget | ||
| const start = input.selectionStart ?? 0 | ||
| const end = input.selectionEnd ?? 0 | ||
| const newValue = input.value.slice(0, start) + data + input.value.slice(end) | ||
|
|
||
| if (!extractNumber(newValue, { maxDecimals })) { | ||
| e.preventDefault() | ||
| } | ||
| } |
There was a problem hiding this comment.
handleBeforeInput currently only prevents input when extractNumber(newValue) returns null (regex mismatch). extractNumber can also truncate/normalize input (e.g. extra decimals beyond maxDecimals), so those characters can still enter the DOM and then persist visually because InputField’s localValue won’t resync when the controlled value string doesn’t change. Consider also preventing inserts that would be truncated/ignored by extractNumber (while preserving the intentional partial states like a trailing decimal separator).
| inputMode="decimal" | ||
| onChange={handleChange} | ||
| {...props} | ||
| onBeforeInput={handleBeforeInput} |
There was a problem hiding this comment.
This PR introduces new user-facing behavior (character filtering via onBeforeInput), but there are no tests covering it. Since this component already has a NumberInput.test.tsx suite, add cases asserting that non-numeric characters (letters/symbols, and paste) are prevented and don’t update the displayed value or trigger onChange.
Why
NumberInputusestype="text"(instead oftype="number")to support locale-specific decimal separators (both,and.) and full control over formatting. However, there was no mechanism to prevent non-numeric characters at the input level.The existing
handleChange→extractNumberpipeline only validated after the character entered the DOM. Due toInputFieldmaintaining its own internallocalValuestate independently fromNumberInput's fieldValue, rejecting input inhandleChangedidn't reset the displayed value —InputField'suseEffectthat syncs from the value prop wouldn't fire because the prop value hadn't changed. This caused letters and symbols to visually persist in the input even thoughonChangenever emitted invalid values.onBeforeInputintercepts characters before the DOM mutation, preventingInputFieldfrom ever seeing invalid input — which is the standard approach fortype="text"inputs that need character filtering (used byreact-number-format,react-imask, etc.).What
Add
onBeforeInputhandler toNumberInputInternalthat prevents non-numeric characters from entering the input field. The handler simulates the resulting value before the DOM mutation and callse.preventDefault()if the result wouldn't match the number format regex.Also simplifies the
handleChangelogic by using the already-extractedformattedValuedirectly and renamingfinalValue→clampedValuefor clarity.Before
Screen.Recording.2026-03-25.at.16.44.04.mov
After
Screen.Recording.2026-03-25.at.16.45.11.mov