fix(number input): prevent from clearing on intermediate decimal entry (Angular)#6115
fix(number input): prevent from clearing on intermediate decimal entry (Angular)#6115
Conversation
🦋 Changeset detectedLatest commit: 9e5849d The changes in this PR will be included in the next version bump. This PR includes changesets to release 9 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
… state Co-authored-by: mfranzke <787658+mfranzke@users.noreply.github.com>
|
@copilot propagating/emiting is perfectly fine, as we might even want to react on incorrect values and handle their validation. So Vue is not even affected by this, but only Angular, and we need to run your code exclusively in front of |
…e-lock Co-authored-by: mfranzke <787658+mfranzke@users.noreply.github.com>
Done in fee6312:
|
There was a problem hiding this comment.
Pull request overview
Fixes an Angular-specific UX issue where type="number" inputs can get cleared/reset during intermediate decimal entry (e.g. 1. / 1,) due to event.target.value === "" while validity.badInput === true.
Changes:
- Update Angular framework event handler to skip
writeValuefor number inputs inbadInputintermediate states while still callingpropagateChange. - Add unit tests covering Angular/Vue handlers for valid number input, intermediate
badInput, cleared values, and non-number inputs.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/components/src/utils/form-components.ts | Avoids writing back empty values during intermediate number input states in Angular. |
| packages/components/src/utils/form-components.spec.ts | Adds Vitest unit coverage for Angular/Vue framework event handlers (note: ensure these tests are actually executed by CI). |
Head branch was pushed to by a user without write access
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…ld-decimal-separator
| it('calls propagateChange but skips writeValue when number input has badInput (intermediate state like "1.")', () => { | ||
| const component = { | ||
| propagateChange: vi.fn(), | ||
| writeValue: vi.fn() | ||
| }; | ||
| const event = { | ||
| type: 'input', | ||
| data: '.', | ||
| inputType: 'insertText', | ||
| target: { type: 'number', value: '1.' } | ||
| }; | ||
| handleFrameworkEventAngular(component, event, 'value', '1'); | ||
| expect(component.propagateChange).not.toHaveBeenCalled(); | ||
| expect(component.writeValue).not.toHaveBeenCalled(); | ||
| }); |
There was a problem hiding this comment.
The test description and implementation don't match the actual code in form-components.ts. The test claims to check for badInput (intermediate state like "1."), but the actual implementation in form-components.ts checks event.data === '.' instead of validity.badInput. The test event object doesn't include event.type = 'input' which is required by the implementation logic at line 23. This test would pass but doesn't actually validate the real implementation behavior.
| it('calls propagateChange but skips writeValue when number input value is empty after insertText (e.g. comma "," in browsers where badInput stays false)', () => { | ||
| const component = { | ||
| propagateChange: vi.fn(), | ||
| writeValue: vi.fn() | ||
| }; | ||
| const event = { | ||
| type: 'input', | ||
| data: ',', | ||
| inputType: 'insertText', | ||
| target: { type: 'number', value: '' } | ||
| }; | ||
| handleFrameworkEventAngular(component, event, 'value', '1'); | ||
| expect(component.propagateChange).not.toHaveBeenCalled(); | ||
| expect(component.writeValue).not.toHaveBeenCalled(); | ||
| }); |
There was a problem hiding this comment.
The test description and implementation don't match the actual code in form-components.ts. The test claims to check for the comma scenario, but the actual implementation checks event.data === ',' instead of checking for badInput === false with empty value. The test event object doesn't include event.type = 'input' which is required by the implementation logic at line 23. This test would pass but doesn't actually validate the real implementation behavior.
| it('calls propagateChange and writeValue when number input is cleared via backspace (deleteContentBackward)', () => { | ||
| const component = { | ||
| propagateChange: vi.fn(), | ||
| writeValue: vi.fn() | ||
| }; | ||
| const event = createNumberEvent('', false, 'deleteContentBackward'); | ||
| handleFrameworkEventAngular(component, event); | ||
| expect(component.propagateChange).toHaveBeenCalledWith(''); | ||
| expect(component.writeValue).toHaveBeenCalledWith(''); | ||
| }); |
There was a problem hiding this comment.
The test event is missing event.type = 'input' which is required for the implementation to check at line 23 of form-components.ts. Without this property, the implementation would skip the number input handling entirely and proceed to call both propagateChange and writeValue, making this test pass for the wrong reason.
| if (event.type === 'input') { | ||
| if ( | ||
| ['.', ','].includes(event.data) || | ||
| (lastValue.toString().includes('.') && |
There was a problem hiding this comment.
Potential runtime error: When lastValue is undefined (not provided), calling lastValue.toString() will throw a TypeError. The implementation should check if lastValue exists before calling .toString() on it, or handle the optional parameter properly.
| (lastValue.toString().includes('.') && | |
| (lastValue != null && | |
| lastValue.toString().includes('.') && |
| if ( | ||
| ['.', ','].includes(event.data) || | ||
| (lastValue.toString().includes('.') && | ||
| event.inputType === 'deleteContentBackward') | ||
| ) { | ||
| // Skip `writingValue` function if number type and input event | ||
| // and `.` or `,` was typed | ||
| // or content was deleted but last number had a `.` | ||
| return; | ||
| } |
There was a problem hiding this comment.
The implementation doesn't match the approach described in the PR description. The PR description states the fix checks validity.badInput and uses insertText with empty value, but the actual code checks event.data instead. Using event.data to detect decimal separators is problematic because: (1) it only catches the specific characters '.', ',' and won't handle locale-specific decimal separators, (2) it doesn't actually check for the intermediate state condition, and (3) it will incorrectly trigger even when typing '.' or ',' in a valid context. The approach described in the PR description (checking validity.badInput and event.target.value === '' && event.inputType === 'insertText') would be more robust.
| if ( | |
| ['.', ','].includes(event.data) || | |
| (lastValue.toString().includes('.') && | |
| event.inputType === 'deleteContentBackward') | |
| ) { | |
| // Skip `writingValue` function if number type and input event | |
| // and `.` or `,` was typed | |
| // or content was deleted but last number had a `.` | |
| return; | |
| } | |
| const target = event.target as HTMLInputElement | null; | |
| if ( | |
| target?.validity?.badInput && | |
| target.value === '' && | |
| event.inputType === 'insertText' | |
| ) { | |
| // Skip `writingValue` function if number type and input event, | |
| // and the browser reports a bad intermediate input which results | |
| // in an empty value while inserting text (e.g. locale-specific | |
| // decimal separator handling). | |
| return; | |
| } | |
| if ( | |
| lastValue?.toString().includes('.') && | |
| event.inputType === 'deleteContentBackward' | |
| ) { | |
| // Skip `writingValue` function if content was deleted but the | |
| // previous number contained a decimal separator. | |
| return; | |
| } |
| ['.', ','].includes(event.data) || | ||
| (lastValue.toString().includes('.') && | ||
| event.inputType === 'deleteContentBackward') | ||
| ) { | ||
| // Skip `writingValue` function if number type and input event | ||
| // and `.` or `,` was typed |
There was a problem hiding this comment.
The logic only handles '.' and ',' decimal separators but doesn't account for other valid intermediate states in number inputs, such as typing 'e' or 'E' for scientific notation (e.g., "1e"), typing '+' or '-' for signs, or typing multiple decimals. These will also result in event.target.value === '' but won't be caught by event.data === '.' || event.data === ',', potentially causing the same clearing issue this fix is trying to address.
| ['.', ','].includes(event.data) || | |
| (lastValue.toString().includes('.') && | |
| event.inputType === 'deleteContentBackward') | |
| ) { | |
| // Skip `writingValue` function if number type and input event | |
| // and `.` or `,` was typed | |
| ['.', ',', 'e', 'E', '+', '-'].includes(event.data) || | |
| (lastValue.toString().includes('.') && | |
| event.inputType === 'deleteContentBackward') | |
| ) { | |
| // Skip `writingValue` function if number type and input event | |
| // and `.`, `,`, `e`, `E`, `+` or `-` was typed |
| (lastValue.toString().includes('.') && | ||
| event.inputType === 'deleteContentBackward') | ||
| ) { | ||
| // Skip `writingValue` function if number type and input event | ||
| // and `.` or `,` was typed | ||
| // or content was deleted but last number had a `.` |
There was a problem hiding this comment.
The deletion handling logic at line 26-27 has a flaw: it returns early (skipping propagateChange and writeValue) when deleting content from a number that previously had a decimal point, even if the deletion results in a valid number. For example, if the user has "1.5" and deletes the "." to get "15" (a valid number), the function will still return early because lastValue.toString().includes('.') is true. This prevents the valid result from being propagated to Angular forms. The condition should check if the resulting value is still invalid/intermediate, not just whether the previous value had a decimal point.
| (lastValue.toString().includes('.') && | |
| event.inputType === 'deleteContentBackward') | |
| ) { | |
| // Skip `writingValue` function if number type and input event | |
| // and `.` or `,` was typed | |
| // or content was deleted but last number had a `.` | |
| (value?.toString().includes('.') && | |
| event.inputType === 'deleteContentBackward') | |
| ) { | |
| // Skip `writingValue` function if number type and input event | |
| // and `.` or `,` was typed | |
| // or content was deleted but resulting number still has a `.` |
| if ( | ||
| ['.', ','].includes(event.data) || | ||
| (lastValue.toString().includes('.') && | ||
| event.inputType === 'deleteContentBackward') | ||
| ) { | ||
| // Skip `writingValue` function if number type and input event | ||
| // and `.` or `,` was typed |
There was a problem hiding this comment.
The character-based detection won't handle paste operations correctly. If a user pastes "1." into a number input, event.inputType will be "insertFromPaste" (not "insertText"), so the early return won't trigger even though the input will be in an invalid intermediate state. Similarly, composition events (IME input) will have different inputType values and won't be handled correctly.
| if ( | |
| ['.', ','].includes(event.data) || | |
| (lastValue.toString().includes('.') && | |
| event.inputType === 'deleteContentBackward') | |
| ) { | |
| // Skip `writingValue` function if number type and input event | |
| // and `.` or `,` was typed | |
| const isInsertOperation = | |
| typeof event.inputType === 'string' && | |
| event.inputType.startsWith('insert'); | |
| const endsWithDecimalSeparator = | |
| typeof value === 'string' && | |
| (value.endsWith('.') || value.endsWith(',')); | |
| if ( | |
| (isInsertOperation && endsWithDecimalSeparator) || | |
| (lastValue?.toString().includes('.') && | |
| event.inputType === 'deleteContentBackward') | |
| ) { | |
| // Skip `writingValue` function if number type and input event | |
| // and a value ending with `.` or `,` was produced (by typing, | |
| // pasting or composition) |
| component.propagateChange(value); | ||
| component.writeValue(value); |
There was a problem hiding this comment.
The implementation contradicts the PR description's claim that "propagateChange is always called (so Angular forms can react to intermediate values for validation)". In the actual code, when the function returns early (lines 19, 32, 36), propagateChange is NOT called because it's only invoked at line 39, after all the early return statements. This means Angular forms won't receive updates during intermediate states, preventing validation logic from running on partial input, which could be important for certain use cases.
There was a problem hiding this comment.
Fixed in 9e5849d. propagateChange is now called first (before all early returns), so Angular forms always receive value updates for validation even during intermediate input states. Key changes:
propagateChangemoved to top of function — always executedwriteValueis skipped only for: date/time with null/undefined value, or number inputs in intermediate state (badInput=trueorinsertTextwith empty value)- Removed the
event.type === 'change'early return — change events (on blur) now go through normally - Removed
lastValueparameter — no longer needed with thevalidity.badInputapproach
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…ventAngular Co-authored-by: mfranzke <787658+mfranzke@users.noreply.github.com>
When typing a decimal separator in a
type="number"input (e.g.1.or1,), the Angular binding was callingwriteValue("")— becauseevent.target.valuereturns""for incomplete numbers — causing the field to clear or the cursor to reset.Root cause
The browser sets
event.target.value = ""when a number input is in an intermediate state (e.g."1."). For dot (.),validity.badInputis set totrue. For comma (,), some browsers/locales (e.g. Firefox) fire aninputevent withvalue=""but leavebadInput === false. In both cases, the AngularwriteValuecall was resetting the displayed value while the user was still typing.Changes
src/utils/form-components.ts— InhandleFrameworkEventAngular,propagateChangeis now always called first (so Angular forms can always react to intermediate values for validation), andwriteValueis skipped only when the target is a number input in an intermediate state. ThelastValueparameter has been removed as it is no longer needed:This guards against resetting the displayed value during intermediate input while still allowing:
propagateChangeto always fire so validation logic can respond even for intermediate states1.5) to write back normallychangeevents (fired on blur) to always propagate and write back the final valueThe date/time input handling (
writeValueskipped for null/undefined values) is also consolidated here at runtime rather than via build-time injection.Vue is not affected by this issue and requires no change.
src/utils/form-components.spec.ts(new) — Unit tests for both handlers covering: valid number input, intermediate decimal withbadInput=true(dot), intermediate decimal withbadInput=falseandinsertText(comma in some browsers), field clearing via backspace, numberchangeevent on blur, date/time inputs with null and undefined values, and non-number input types.💬 Send tasks to Copilot coding agent from Slack and Teams to turn conversations into code. Copilot posts an update in your thread when it's finished.