Skip to content

Commit 41b1410

Browse files
alxhubleonsenft
authored andcommitted
feat(forms): support binding number|null to <input type="text">
`<input type="number">` often does not provide the desired user experience when editing numbers in a form. MDN even [describes](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/number#using_number_inputs) how text inputs should be used in many cases instead, via `<input type="text" inputmode="numeric">` or similar configurations. Previously, this did not work with Signal Forms without a custom input component/directive. This PR builds support for binding `number|null` models directly to `<input type="text">` native controls via `[formField]`. When a model has a number or `null` value, signal forms will preserve that status when the user makes edits/changes. Empty string values are converted to `null`, other values are parsed as numbers, and a parse error is raised when a non-numeric value is entered. Note that it's up to the UI developer to configure additional UI affordances such as setting an appropriate `inputmode`, rejecting non-numeric keypresses, etc. Fixes #66903 Fixes #66157
1 parent e850643 commit 41b1410

File tree

5 files changed

+347
-15
lines changed

5 files changed

+347
-15
lines changed

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

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -112,21 +112,40 @@ export class TcbNativeFieldOp extends TcbOp {
112112

113113
checkUnsupportedFieldBindings(this.node, this.unsupportedBindingFields, this.tcb);
114114

115-
const expectedType = new TcbExpr(this.getExpectedTypeFromDomNode(this.node));
116-
const value = extractFieldValue(fieldBinding.value, this.tcb, this.scope);
115+
const rawExpectedType = this.getExpectedTypeFromDomNode(this.node);
117116

118-
// Create a variable with the expected type and check that the field value is assignable, e.g.
119-
// var t1 = null! as string | number; t1 = f().value()`.
120-
const id = new TcbExpr(this.tcb.allocateId());
121-
const assignment = new TcbExpr(`${id.print()} = ${value.print()}`);
122-
assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan);
117+
if (rawExpectedType === null) {
118+
// For text-like <input> elements, use an invariant check on the value signal.
119+
// WritableSignal<T> is invariant in T, so assigning it to a union of structural types
120+
// gives us exact type matching: only Field<string> or Field<number|null> are accepted.
121+
const signal = extractFieldValueSignal(fieldBinding.value, this.tcb, this.scope);
122+
const id = new TcbExpr(this.tcb.allocateId());
123+
const unionType = new TcbExpr(
124+
'{ (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; }',
125+
);
126+
const assignment = new TcbExpr(`${id.print()} = ${signal.print()}`);
127+
assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan);
128+
129+
this.scope.addStatement(declareVariable(id, unionType));
130+
this.scope.addStatement(assignment);
131+
} else {
132+
const expectedType = new TcbExpr(rawExpectedType);
133+
const value = extractFieldValue(fieldBinding.value, this.tcb, this.scope);
134+
135+
// Create a variable with the expected type and check that the field value is assignable, e.g.
136+
// var t1 = null! as string | number; t1 = f().value()`.
137+
const id = new TcbExpr(this.tcb.allocateId());
138+
const assignment = new TcbExpr(`${id.print()} = ${value.print()}`);
139+
assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan);
140+
141+
this.scope.addStatement(declareVariable(id, expectedType));
142+
this.scope.addStatement(assignment);
143+
}
123144

124-
this.scope.addStatement(declareVariable(id, expectedType));
125-
this.scope.addStatement(assignment);
126145
return null;
127146
}
128147

129-
private getExpectedTypeFromDomNode(node: TmplAstElement): string {
148+
private getExpectedTypeFromDomNode(node: TmplAstElement): string | null {
130149
if (node.name === 'textarea' || node.name === 'select') {
131150
// `<textarea>` and `<select>` are always strings.
132151
return 'string';
@@ -140,6 +159,9 @@ export class TcbNativeFieldOp extends TcbOp {
140159
case 'checkbox':
141160
return 'boolean';
142161

162+
case 'radio':
163+
return 'string';
164+
143165
case 'number':
144166
case 'range':
145167
case 'datetime-local':
@@ -165,7 +187,11 @@ export class TcbNativeFieldOp extends TcbOp {
165187
return 'string | number | boolean | Date | null';
166188
}
167189

168-
// Fall back to string if we couldn't map the type.
190+
if (this.inputType === 'text' || this.inputType === null) {
191+
// Return null to signal the invariant check for text-like inputs.
192+
return null;
193+
}
194+
169195
return 'string';
170196
}
171197

@@ -416,6 +442,18 @@ function extractFieldValue(expression: AST, tcb: Context, scope: Scope): TcbExpr
416442
return new TcbExpr(`${innerCall.print()}.value()`);
417443
}
418444

445+
/**
446+
* Gets an expression that extracts the value signal of a field binding (without calling it).
447+
*/
448+
function extractFieldValueSignal(expression: AST, tcb: Context, scope: Scope): TcbExpr {
449+
// Unwraps the field, e.g. `[field]="f"` turns into `f()`.
450+
const innerCall = new TcbExpr(tcbExpression(expression, tcb, scope).print() + '()');
451+
innerCall.markIgnoreDiagnostics();
452+
453+
// Extract the value signal from the field, e.g. `f().value` (not called).
454+
return new TcbExpr(`${innerCall.print()}.value`);
455+
}
456+
419457
/** Checks whether a directive has a model-like input with a specific name. */
420458
function hasModelInput(name: string, meta: TcbDirectiveMetadata): boolean {
421459
return (

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2826,8 +2826,10 @@ describe('type check blocks', () => {
28262826

28272827
it('should generate a string field for an input without a type', () => {
28282828
const block = tcb('<input [formField]="f"/>', [FieldMock]);
2829-
expect(block).toContain('var _t1 = null! as string;');
2830-
expect(block).toContain('_t1 = ((this).f)().value();');
2829+
expect(block).toContain(
2830+
'var _t1 = null! as { (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; };',
2831+
);
2832+
expect(block).toContain('_t1 = ((this).f)().value;');
28312833
expect(block).toContain('var _t2 = null! as i0.FormField;');
28322834
expect(block).toContain('_t2.field = (((this).f));');
28332835
});
@@ -2849,7 +2851,6 @@ describe('type check blocks', () => {
28492851
});
28502852

28512853
[
2852-
{inputType: 'text', expectedType: 'string'},
28532854
{inputType: 'radio', expectedType: 'string'},
28542855
{inputType: 'checkbox', expectedType: 'boolean'},
28552856
{inputType: 'number', expectedType: 'string | number | null'},
@@ -2870,6 +2871,16 @@ describe('type check blocks', () => {
28702871
});
28712872
});
28722873

2874+
it(`should generate a structural union for an input with a 'text' type`, () => {
2875+
const block = tcb(`<input type="text" [formField]="f"/>`, [FieldMock]);
2876+
expect(block).toContain(
2877+
'var _t1 = null! as { (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; };',
2878+
);
2879+
expect(block).toContain('_t1 = ((this).f)().value;');
2880+
expect(block).toContain('var _t2 = null! as i0.FormField;');
2881+
expect(block).toContain('_t2.field = (((this).f));');
2882+
});
2883+
28732884
it('should generate expressions to check the field and value bindings of a radio input', () => {
28742885
const block = tcb('<input type="radio" [formField]="f" [value]="expr"/>', [FieldMock]);
28752886
expect(block).toContain('var _t1 = null! as string;');

packages/compiler-cli/test/ngtsc/signal_forms_spec.ts

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ runInEachFileSystem(() => {
106106
expect(extractMessage(diags[0])).toBe(`Type 'null' is not assignable to type 'string'.`);
107107
});
108108

109-
it('should infer an input without a `type` as a string field', () => {
109+
it('should reject a numeric field without null on a bare input', () => {
110110
env.write(
111111
'test.ts',
112112
`
@@ -123,6 +123,120 @@ runInEachFileSystem(() => {
123123
`,
124124
);
125125

126+
const diags = env.driveDiagnostics();
127+
expect(diags.length).toBe(1);
128+
expect(extractMessage(diags[0])).toContain(
129+
`is not assignable to type '{ (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; }'`,
130+
);
131+
});
132+
133+
it('should allow a string field on a bare input', () => {
134+
env.write(
135+
'test.ts',
136+
`
137+
import {Component, signal} from '@angular/core';
138+
import {FormField, form} from '@angular/forms/signals';
139+
140+
@Component({
141+
template: '<input [formField]="f"/>',
142+
imports: [FormField]
143+
})
144+
export class Comp {
145+
f = form(signal(''));
146+
}
147+
`,
148+
);
149+
150+
const diags = env.driveDiagnostics();
151+
expect(diags.length).toBe(0);
152+
});
153+
154+
it('should allow a number|null field on a text input', () => {
155+
env.write(
156+
'test.ts',
157+
`
158+
import {Component, signal} from '@angular/core';
159+
import {FormField, form} from '@angular/forms/signals';
160+
161+
@Component({
162+
template: '<input type="text" [formField]="f"/>',
163+
imports: [FormField]
164+
})
165+
export class Comp {
166+
f = form(signal<number | null>(42));
167+
}
168+
`,
169+
);
170+
171+
const diags = env.driveDiagnostics();
172+
expect(diags.length).toBe(0);
173+
});
174+
175+
it('should reject a string|null field on a text input', () => {
176+
env.write(
177+
'test.ts',
178+
`
179+
import {Component, signal} from '@angular/core';
180+
import {FormField, form} from '@angular/forms/signals';
181+
182+
@Component({
183+
template: '<input type="text" [formField]="f"/>',
184+
imports: [FormField]
185+
})
186+
export class Comp {
187+
f = form(signal<string | null>(null));
188+
}
189+
`,
190+
);
191+
192+
const diags = env.driveDiagnostics();
193+
expect(diags.length).toBe(1);
194+
expect(extractMessage(diags[0])).toContain(
195+
`is not assignable to type '{ (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; }'`,
196+
);
197+
});
198+
199+
it('should reject a boolean field on a text input', () => {
200+
env.write(
201+
'test.ts',
202+
`
203+
import {Component, signal} from '@angular/core';
204+
import {FormField, form} from '@angular/forms/signals';
205+
206+
@Component({
207+
template: '<input type="text" [formField]="f"/>',
208+
imports: [FormField]
209+
})
210+
export class Comp {
211+
f = form(signal(true));
212+
}
213+
`,
214+
);
215+
216+
const diags = env.driveDiagnostics();
217+
expect(diags.length).toBe(1);
218+
expect(extractMessage(diags[0])).toContain(
219+
`is not assignable to type '{ (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; }'`,
220+
);
221+
});
222+
223+
it('should reject a number field on a textarea', () => {
224+
env.write(
225+
'test.ts',
226+
`
227+
import {Component, signal} from '@angular/core';
228+
import {FormField, form} from '@angular/forms/signals';
229+
230+
@Component({
231+
template: '<textarea [formField]="f"></textarea>',
232+
imports: [FormField]
233+
})
234+
export class Comp {
235+
f = form(signal(0));
236+
}
237+
`,
238+
);
239+
126240
const diags = env.driveDiagnostics();
127241
expect(diags.length).toBe(1);
128242
expect(extractMessage(diags[0])).toBe(`Type 'number' is not assignable to type 'string'.`);

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ export function getNativeControlValue(
7474
break;
7575
}
7676

77+
// For text-like <input> elements, parse numeric values if the model is numeric.
78+
if (element.tagName === 'INPUT' && element.type === 'text') {
79+
modelValue ??= untracked(currentValue);
80+
if (typeof modelValue === 'number' || modelValue === null) {
81+
if (element.value === '') {
82+
return {value: null};
83+
}
84+
const parsed = Number(element.value);
85+
if (Number.isNaN(parsed)) {
86+
return {error: new NativeInputParseError() as WithoutFieldTree<NativeInputParseError>};
87+
}
88+
return {value: parsed};
89+
}
90+
}
91+
7792
// Default to reading the value as a string.
7893
return {value: element.value};
7994
}
@@ -121,6 +136,18 @@ export function setNativeControlValue(element: NativeFormControl, value: unknown
121136
}
122137
}
123138

139+
// For text-like <input> elements, handle numeric and null values.
140+
if (element.tagName === 'INPUT' && element.type === 'text') {
141+
if (typeof value === 'number') {
142+
element.value = isNaN(value) ? '' : String(value);
143+
return;
144+
}
145+
if (value === null) {
146+
element.value = '';
147+
return;
148+
}
149+
}
150+
124151
// Default to setting the value as a string.
125152
element.value = value as string;
126153
}

0 commit comments

Comments
 (0)