mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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
This commit is contained in:
committed by
Leon Senft
parent
e850643b1b
commit
41b1410cb8
@@ -112,21 +112,40 @@ export class TcbNativeFieldOp extends TcbOp {
|
||||
|
||||
checkUnsupportedFieldBindings(this.node, this.unsupportedBindingFields, this.tcb);
|
||||
|
||||
const expectedType = new TcbExpr(this.getExpectedTypeFromDomNode(this.node));
|
||||
const value = extractFieldValue(fieldBinding.value, this.tcb, this.scope);
|
||||
const rawExpectedType = this.getExpectedTypeFromDomNode(this.node);
|
||||
|
||||
// Create a variable with the expected type and check that the field value is assignable, e.g.
|
||||
// var t1 = null! as string | number; t1 = f().value()`.
|
||||
const id = new TcbExpr(this.tcb.allocateId());
|
||||
const assignment = new TcbExpr(`${id.print()} = ${value.print()}`);
|
||||
assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan);
|
||||
if (rawExpectedType === null) {
|
||||
// For text-like <input> elements, use an invariant check on the value signal.
|
||||
// WritableSignal<T> is invariant in T, so assigning it to a union of structural types
|
||||
// gives us exact type matching: only Field<string> or Field<number|null> are accepted.
|
||||
const signal = extractFieldValueSignal(fieldBinding.value, this.tcb, this.scope);
|
||||
const id = new TcbExpr(this.tcb.allocateId());
|
||||
const unionType = new TcbExpr(
|
||||
'{ (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; }',
|
||||
);
|
||||
const assignment = new TcbExpr(`${id.print()} = ${signal.print()}`);
|
||||
assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan);
|
||||
|
||||
this.scope.addStatement(declareVariable(id, unionType));
|
||||
this.scope.addStatement(assignment);
|
||||
} else {
|
||||
const expectedType = new TcbExpr(rawExpectedType);
|
||||
const value = extractFieldValue(fieldBinding.value, this.tcb, this.scope);
|
||||
|
||||
// Create a variable with the expected type and check that the field value is assignable, e.g.
|
||||
// var t1 = null! as string | number; t1 = f().value()`.
|
||||
const id = new TcbExpr(this.tcb.allocateId());
|
||||
const assignment = new TcbExpr(`${id.print()} = ${value.print()}`);
|
||||
assignment.addParseSpanInfo(fieldBinding.valueSpan ?? fieldBinding.sourceSpan);
|
||||
|
||||
this.scope.addStatement(declareVariable(id, expectedType));
|
||||
this.scope.addStatement(assignment);
|
||||
}
|
||||
|
||||
this.scope.addStatement(declareVariable(id, expectedType));
|
||||
this.scope.addStatement(assignment);
|
||||
return null;
|
||||
}
|
||||
|
||||
private getExpectedTypeFromDomNode(node: TmplAstElement): string {
|
||||
private getExpectedTypeFromDomNode(node: TmplAstElement): string | null {
|
||||
if (node.name === 'textarea' || node.name === 'select') {
|
||||
// `<textarea>` and `<select>` are always strings.
|
||||
return 'string';
|
||||
@@ -140,6 +159,9 @@ export class TcbNativeFieldOp extends TcbOp {
|
||||
case 'checkbox':
|
||||
return 'boolean';
|
||||
|
||||
case 'radio':
|
||||
return 'string';
|
||||
|
||||
case 'number':
|
||||
case 'range':
|
||||
case 'datetime-local':
|
||||
@@ -165,7 +187,11 @@ export class TcbNativeFieldOp extends TcbOp {
|
||||
return 'string | number | boolean | Date | null';
|
||||
}
|
||||
|
||||
// Fall back to string if we couldn't map the type.
|
||||
if (this.inputType === 'text' || this.inputType === null) {
|
||||
// Return null to signal the invariant check for text-like inputs.
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'string';
|
||||
}
|
||||
|
||||
@@ -416,6 +442,18 @@ function extractFieldValue(expression: AST, tcb: Context, scope: Scope): TcbExpr
|
||||
return new TcbExpr(`${innerCall.print()}.value()`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an expression that extracts the value signal of a field binding (without calling it).
|
||||
*/
|
||||
function extractFieldValueSignal(expression: AST, tcb: Context, scope: Scope): TcbExpr {
|
||||
// Unwraps the field, e.g. `[field]="f"` turns into `f()`.
|
||||
const innerCall = new TcbExpr(tcbExpression(expression, tcb, scope).print() + '()');
|
||||
innerCall.markIgnoreDiagnostics();
|
||||
|
||||
// Extract the value signal from the field, e.g. `f().value` (not called).
|
||||
return new TcbExpr(`${innerCall.print()}.value`);
|
||||
}
|
||||
|
||||
/** Checks whether a directive has a model-like input with a specific name. */
|
||||
function hasModelInput(name: string, meta: TcbDirectiveMetadata): boolean {
|
||||
return (
|
||||
|
||||
@@ -2826,8 +2826,10 @@ describe('type check blocks', () => {
|
||||
|
||||
it('should generate a string field for an input without a type', () => {
|
||||
const block = tcb('<input [formField]="f"/>', [FieldMock]);
|
||||
expect(block).toContain('var _t1 = null! as string;');
|
||||
expect(block).toContain('_t1 = ((this).f)().value();');
|
||||
expect(block).toContain(
|
||||
'var _t1 = null! as { (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; };',
|
||||
);
|
||||
expect(block).toContain('_t1 = ((this).f)().value;');
|
||||
expect(block).toContain('var _t2 = null! as i0.FormField;');
|
||||
expect(block).toContain('_t2.field = (((this).f));');
|
||||
});
|
||||
@@ -2849,7 +2851,6 @@ describe('type check blocks', () => {
|
||||
});
|
||||
|
||||
[
|
||||
{inputType: 'text', expectedType: 'string'},
|
||||
{inputType: 'radio', expectedType: 'string'},
|
||||
{inputType: 'checkbox', expectedType: 'boolean'},
|
||||
{inputType: 'number', expectedType: 'string | number | null'},
|
||||
@@ -2870,6 +2871,16 @@ describe('type check blocks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it(`should generate a structural union for an input with a 'text' type`, () => {
|
||||
const block = tcb(`<input type="text" [formField]="f"/>`, [FieldMock]);
|
||||
expect(block).toContain(
|
||||
'var _t1 = null! as { (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; };',
|
||||
);
|
||||
expect(block).toContain('_t1 = ((this).f)().value;');
|
||||
expect(block).toContain('var _t2 = null! as i0.FormField;');
|
||||
expect(block).toContain('_t2.field = (((this).f));');
|
||||
});
|
||||
|
||||
it('should generate expressions to check the field and value bindings of a radio input', () => {
|
||||
const block = tcb('<input type="radio" [formField]="f" [value]="expr"/>', [FieldMock]);
|
||||
expect(block).toContain('var _t1 = null! as string;');
|
||||
|
||||
@@ -106,7 +106,7 @@ runInEachFileSystem(() => {
|
||||
expect(extractMessage(diags[0])).toBe(`Type 'null' is not assignable to type 'string'.`);
|
||||
});
|
||||
|
||||
it('should infer an input without a `type` as a string field', () => {
|
||||
it('should reject a numeric field without null on a bare input', () => {
|
||||
env.write(
|
||||
'test.ts',
|
||||
`
|
||||
@@ -123,6 +123,120 @@ runInEachFileSystem(() => {
|
||||
`,
|
||||
);
|
||||
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(extractMessage(diags[0])).toContain(
|
||||
`is not assignable to type '{ (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; }'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow a string field on a bare input', () => {
|
||||
env.write(
|
||||
'test.ts',
|
||||
`
|
||||
import {Component, signal} from '@angular/core';
|
||||
import {FormField, form} from '@angular/forms/signals';
|
||||
|
||||
@Component({
|
||||
template: '<input [formField]="f"/>',
|
||||
imports: [FormField]
|
||||
})
|
||||
export class Comp {
|
||||
f = form(signal(''));
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should allow a number|null field on a text input', () => {
|
||||
env.write(
|
||||
'test.ts',
|
||||
`
|
||||
import {Component, signal} from '@angular/core';
|
||||
import {FormField, form} from '@angular/forms/signals';
|
||||
|
||||
@Component({
|
||||
template: '<input type="text" [formField]="f"/>',
|
||||
imports: [FormField]
|
||||
})
|
||||
export class Comp {
|
||||
f = form(signal<number | null>(42));
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should reject a string|null field on a text input', () => {
|
||||
env.write(
|
||||
'test.ts',
|
||||
`
|
||||
import {Component, signal} from '@angular/core';
|
||||
import {FormField, form} from '@angular/forms/signals';
|
||||
|
||||
@Component({
|
||||
template: '<input type="text" [formField]="f"/>',
|
||||
imports: [FormField]
|
||||
})
|
||||
export class Comp {
|
||||
f = form(signal<string | null>(null));
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(extractMessage(diags[0])).toContain(
|
||||
`is not assignable to type '{ (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; }'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a boolean field on a text input', () => {
|
||||
env.write(
|
||||
'test.ts',
|
||||
`
|
||||
import {Component, signal} from '@angular/core';
|
||||
import {FormField, form} from '@angular/forms/signals';
|
||||
|
||||
@Component({
|
||||
template: '<input type="text" [formField]="f"/>',
|
||||
imports: [FormField]
|
||||
})
|
||||
export class Comp {
|
||||
f = form(signal(true));
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(extractMessage(diags[0])).toContain(
|
||||
`is not assignable to type '{ (): string; set: (v: string) => void; } | { (): number | null; set: (v: number | null) => void; }'`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a number field on a textarea', () => {
|
||||
env.write(
|
||||
'test.ts',
|
||||
`
|
||||
import {Component, signal} from '@angular/core';
|
||||
import {FormField, form} from '@angular/forms/signals';
|
||||
|
||||
@Component({
|
||||
template: '<textarea [formField]="f"></textarea>',
|
||||
imports: [FormField]
|
||||
})
|
||||
export class Comp {
|
||||
f = form(signal(0));
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
const diags = env.driveDiagnostics();
|
||||
expect(diags.length).toBe(1);
|
||||
expect(extractMessage(diags[0])).toBe(`Type 'number' is not assignable to type 'string'.`);
|
||||
|
||||
@@ -74,6 +74,21 @@ export function getNativeControlValue(
|
||||
break;
|
||||
}
|
||||
|
||||
// For text-like <input> elements, parse numeric values if the model is numeric.
|
||||
if (element.tagName === 'INPUT' && element.type === 'text') {
|
||||
modelValue ??= untracked(currentValue);
|
||||
if (typeof modelValue === 'number' || modelValue === null) {
|
||||
if (element.value === '') {
|
||||
return {value: null};
|
||||
}
|
||||
const parsed = Number(element.value);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return {error: new NativeInputParseError() as WithoutFieldTree<NativeInputParseError>};
|
||||
}
|
||||
return {value: parsed};
|
||||
}
|
||||
}
|
||||
|
||||
// Default to reading the value as a string.
|
||||
return {value: element.value};
|
||||
}
|
||||
@@ -121,6 +136,18 @@ export function setNativeControlValue(element: NativeFormControl, value: unknown
|
||||
}
|
||||
}
|
||||
|
||||
// For text-like <input> elements, handle numeric and null values.
|
||||
if (element.tagName === 'INPUT' && element.type === 'text') {
|
||||
if (typeof value === 'number') {
|
||||
element.value = isNaN(value) ? '' : String(value);
|
||||
return;
|
||||
}
|
||||
if (value === null) {
|
||||
element.value = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to setting the value as a string.
|
||||
element.value = value as string;
|
||||
}
|
||||
|
||||
@@ -162,6 +162,148 @@ describe('numeric inputs', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('text input with numeric model', () => {
|
||||
it('should render numeric model value as string', () => {
|
||||
@Component({
|
||||
imports: [FormField],
|
||||
template: `<input type="text" [formField]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly data = signal<number | null>(42);
|
||||
readonly f = form(this.data);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
||||
|
||||
expect(input.value).toBe('42');
|
||||
});
|
||||
|
||||
it('should update model as a number when user types a valid number', () => {
|
||||
@Component({
|
||||
imports: [FormField],
|
||||
template: `<input type="text" [formField]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly data = signal<number | null>(0);
|
||||
readonly f = form(this.data);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
||||
|
||||
act(() => {
|
||||
input.value = '123';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
|
||||
expect(fixture.componentInstance.f().value()).toBe(123);
|
||||
expect(fixture.componentInstance.f().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should produce a parse error when user types non-numeric text', () => {
|
||||
@Component({
|
||||
imports: [FormField],
|
||||
template: `<input type="text" [formField]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly data = signal<number | null>(42);
|
||||
readonly f = form(this.data);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
||||
|
||||
act(() => {
|
||||
input.value = 'abc';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
|
||||
expect(fixture.componentInstance.f().value()).toBe(42);
|
||||
expect(fixture.componentInstance.f().errors()).toEqual([
|
||||
jasmine.objectContaining({kind: 'parse'}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should set model to null when input is cleared', () => {
|
||||
@Component({
|
||||
imports: [FormField],
|
||||
template: `<input type="text" [formField]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly data = signal<number | null>(42);
|
||||
readonly f = form(this.data);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
||||
|
||||
act(() => {
|
||||
input.value = '';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
|
||||
expect(fixture.componentInstance.f().value()).toBeNull();
|
||||
expect(fixture.componentInstance.f().errors()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should render null model value as empty string', () => {
|
||||
@Component({
|
||||
imports: [FormField],
|
||||
template: `<input type="text" [formField]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly data = signal<number | null>(null);
|
||||
readonly f = form(this.data);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
||||
|
||||
expect(input.value).toBe('');
|
||||
expect(fixture.componentInstance.f().value()).toBeNull();
|
||||
});
|
||||
|
||||
it('should render NaN model value as empty string', () => {
|
||||
@Component({
|
||||
imports: [FormField],
|
||||
template: `<input type="text" [formField]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly data = signal<number | null>(NaN);
|
||||
readonly f = form(this.data);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
||||
|
||||
expect(input.value).toBe('');
|
||||
expect(fixture.componentInstance.f().value()).toEqual(NaN);
|
||||
});
|
||||
|
||||
it('should update input when model is set programmatically', () => {
|
||||
@Component({
|
||||
imports: [FormField],
|
||||
template: `<input type="text" [formField]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly data = signal<number | null>(10);
|
||||
readonly f = form(this.data);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
|
||||
|
||||
expect(input.value).toBe('10');
|
||||
|
||||
act(() => {
|
||||
fixture.componentInstance.data.set(99);
|
||||
});
|
||||
|
||||
expect(input.value).toBe('99');
|
||||
});
|
||||
});
|
||||
|
||||
function act<T>(fn: () => T): T {
|
||||
try {
|
||||
return fn();
|
||||
|
||||
Reference in New Issue
Block a user