Skip to content

Commit 30f0914

Browse files
mmalerbamattrbeck
authored andcommitted
feat(forms): support binding null to number input (#66917)
Supports binding `null` to a `<input type=number>`. - Binding in `null` clears the input - Binding in `NaN` also clears the input - When the user clears the input, the model is set to `null` - The model is _never_ set to `NaN` based on user interaction. It is either set to `null` if the user cleared the input, or is unchanged and a parse error added if the user entered an invalid number like "42e" PR Close #66917
1 parent 22afbb2 commit 30f0914

4 files changed

Lines changed: 91 additions & 10 deletions

File tree

packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/*!
1+
/**
22
* @license
33
* Copyright Google LLC All Rights Reserved.
44
*
@@ -147,6 +147,7 @@ export class TcbNativeFieldOp extends TcbOp {
147147
return ts.factory.createUnionTypeNode([
148148
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
149149
ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword),
150+
ts.factory.createLiteralTypeNode(ts.factory.createNull()),
150151
]);
151152

152153
case 'date':

packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2728,9 +2728,9 @@ describe('type check blocks', () => {
27282728
{inputType: 'text', expectedType: 'string'},
27292729
{inputType: 'radio', expectedType: 'string'},
27302730
{inputType: 'checkbox', expectedType: 'boolean'},
2731-
{inputType: 'number', expectedType: 'string | number'},
2732-
{inputType: 'range', expectedType: 'string | number'},
2733-
{inputType: 'datetime-local', expectedType: 'string | number'},
2731+
{inputType: 'number', expectedType: 'string | number | null'},
2732+
{inputType: 'range', expectedType: 'string | number | null'},
2733+
{inputType: 'datetime-local', expectedType: 'string | number | null'},
27342734
{inputType: 'date', expectedType: 'string | number | Date | null'},
27352735
{inputType: 'month', expectedType: 'string | number | Date | null'},
27362736
{inputType: 'time', expectedType: 'string | number | Date | null'},

packages/forms/signals/src/directive/native.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export function getNativeControlValue(
7070
element: NativeFormControl,
7171
currentValue: () => unknown,
7272
): NativeControlValue {
73+
let modelValue: unknown;
74+
7375
if (element.validity.badInput) {
7476
return {
7577
errors: [new NativeInputParseError() as WithoutFieldTree<NativeInputParseError>],
@@ -85,8 +87,9 @@ export function getNativeControlValue(
8587
case 'datetime-local':
8688
// We can read a `number` or a `string` from this input type. Prefer whichever is consistent
8789
// with the current type.
88-
if (typeof untracked(currentValue) === 'number') {
89-
return {value: element.valueAsNumber};
90+
modelValue = untracked(currentValue);
91+
if (typeof modelValue === 'number' || modelValue === null) {
92+
return {value: element.value === '' ? null : element.valueAsNumber};
9093
}
9194
break;
9295
case 'date':
@@ -95,10 +98,10 @@ export function getNativeControlValue(
9598
case 'week':
9699
// We can read a `Date | null`, `number`, or `string` from this input type. Prefer whichever
97100
// is consistent with the current type.
98-
const value = untracked(currentValue);
99-
if (value === null || value instanceof Date) {
101+
modelValue = untracked(currentValue);
102+
if (modelValue === null || modelValue instanceof Date) {
100103
return {value: element.valueAsDate};
101-
} else if (typeof value === 'number') {
104+
} else if (typeof modelValue === 'number') {
102105
return {value: element.valueAsNumber};
103106
}
104107
break;
@@ -132,6 +135,9 @@ export function setNativeControlValue(element: NativeFormControl, value: unknown
132135
if (typeof value === 'number') {
133136
setNativeNumberControlValue(element, value);
134137
return;
138+
} else if (value === null) {
139+
element.value = '';
140+
return;
135141
}
136142
break;
137143
case 'date':

packages/forms/signals/test/web/number_input.spec.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,74 @@ describe('numeric inputs', () => {
9292
expect(input2.value).toBe('42');
9393
});
9494
});
95+
96+
describe('nullability', () => {
97+
it('should initialize with null', () => {
98+
@Component({
99+
imports: [FormField],
100+
template: `<input type="number" [formField]="f" />`,
101+
})
102+
class TestCmp {
103+
readonly data = signal<number | null>(null);
104+
readonly f = form(this.data);
105+
}
106+
107+
const fixture = act(() => TestBed.createComponent(TestCmp));
108+
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
109+
110+
expect(input.value).toBe('');
111+
expect(fixture.componentInstance.f().value()).toBeNull();
112+
expect(fixture.componentInstance.f().errors()).toEqual([]);
113+
});
114+
115+
it('should initialize with NaN', () => {
116+
@Component({
117+
imports: [FormField],
118+
template: `<input type="number" [formField]="f" />`,
119+
})
120+
class TestCmp {
121+
readonly data = signal<number | null>(NaN);
122+
readonly f = form(this.data);
123+
}
124+
125+
const fixture = act(() => TestBed.createComponent(TestCmp));
126+
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
127+
128+
expect(input.value).toBe('');
129+
expect(fixture.componentInstance.f().value()).toEqual(NaN);
130+
// No parse errors if its `NaN` from the model
131+
expect(fixture.componentInstance.f().errors()).toEqual([]);
132+
});
133+
134+
it('should update model to null when user clears input', () => {
135+
@Component({
136+
imports: [FormField],
137+
template: `<input type="number" [formField]="f" />`,
138+
})
139+
class TestCmp {
140+
readonly data = signal<number | null>(NaN);
141+
readonly f = form(this.data);
142+
}
143+
144+
const fixture = act(() => TestBed.createComponent(TestCmp));
145+
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
146+
patchNumberInput(input);
147+
148+
act(() => {
149+
input.value = '4';
150+
input.dispatchEvent(new Event('input'));
151+
});
152+
153+
expect(fixture.componentInstance.f().value()).toBe(4);
154+
155+
act(() => {
156+
input.value = '';
157+
input.dispatchEvent(new Event('input'));
158+
});
159+
160+
expect(fixture.componentInstance.f().value()).toBeNull();
161+
});
162+
});
95163
});
96164

97165
function act<T>(fn: () => T): T {
@@ -102,7 +170,13 @@ function act<T>(fn: () => T): T {
102170
}
103171
}
104172

105-
/** Patch a number input to make its validity work as it would in a normal browser. */
173+
/**
174+
* Patch a number input to make its validity work as it would if the user was actually typing.
175+
*
176+
* `validity.badInput` is updated when the user types in the `<input>`, but when we simulate it
177+
* by setting the value and dispatching an event, that flag is not updated. To work around this
178+
* we patch the input.
179+
*/
106180
function patchNumberInput(input: HTMLInputElement) {
107181
let value = input.value;
108182
Object.defineProperties(input, {

0 commit comments

Comments
 (0)