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:
Alex Rickabaugh
2026-02-24 18:16:54 -08:00
committed by Leon Senft
parent e850643b1b
commit 41b1410cb8
5 changed files with 347 additions and 15 deletions
@@ -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();