feat(forms): add parsing support to native inputs (#66917)

Integrates native inputs with the new parseErrors API so that they can
report parse errors when the user types an un-parsable value (e.g. "42e"
in a number field).

When a user types an un-parsable value, the model does not update. It
retains its previous value and a parse error is added for the control
that received the un-parsable value.

PR Close #66917
This commit is contained in:
Miles Malerba 2026-02-07 10:01:47 -08:00 committed by Matt Beck
parent 27397b3f4f
commit 22afbb2f36
6 changed files with 196 additions and 17 deletions

View file

@ -418,11 +418,17 @@ export class MinValidationError extends BaseNgValidationError {
readonly min: number;
}
// @public
export class NativeInputParseError extends BaseNgValidationError {
// (undocumented)
readonly kind = "parse";
}
// @public
export const NgValidationError: abstract new () => NgValidationError;
// @public (undocumented)
export type NgValidationError = RequiredValidationError | MinValidationError | MaxValidationError | MinLengthValidationError | MaxLengthValidationError | PatternValidationError | EmailValidationError | StandardSchemaValidationError;
export type NgValidationError = RequiredValidationError | MinValidationError | MaxValidationError | MinLengthValidationError | MaxLengthValidationError | PatternValidationError | EmailValidationError | StandardSchemaValidationError | NativeInputParseError;
// @public
export type OneOrMany<T> = T | readonly T[];

View file

@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {ValidationErrors} from '@angular/forms';
import type {FormField} from '../../../directive/form_field_directive';
import type {FieldTree} from '../../types';
import type {StandardSchemaValidationError} from './standard_schema';
@ -453,6 +452,16 @@ export class EmailValidationError extends BaseNgValidationError {
override readonly kind = 'email';
}
/**
* An error used to indicate that a value entered in a native input does not parse.
*
* @category validation
* @experimental 21.2.0
*/
export class NativeInputParseError extends BaseNgValidationError {
override readonly kind = 'parse';
}
/**
* The base class for all built-in, non-custom errors. This class can be used to check if an error
* is one of the standard kinds, allowing you to switch on the kind to further narrow the type.
@ -487,4 +496,5 @@ export type NgValidationError =
| MaxLengthValidationError
| PatternValidationError
| EmailValidationError
| StandardSchemaValidationError;
| StandardSchemaValidationError
| NativeInputParseError;

View file

@ -5,28 +5,47 @@
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import type {ɵControlDirectiveHost as ControlDirectiveHost} from '@angular/core';
import type {FormField} from './form_field_directive';
import {getNativeControlValue, setNativeControlValue, setNativeDomProperty} from './native';
import {observeSelectMutations} from './select';
import {
linkedSignal,
type ɵControlDirectiveHost as ControlDirectiveHost,
type Signal,
type WritableSignal,
} from '@angular/core';
import type {ValidationError} from '../api/rules';
import {
bindingUpdated,
CONTROL_BINDING_NAMES,
type ControlBindingKey,
createBindings,
readFieldStateBindingValue,
type ControlBindingKey,
} from './bindings';
import type {FormField} from './form_field_directive';
import {getNativeControlValue, setNativeControlValue, setNativeDomProperty} from './native';
import {observeSelectMutations} from './select';
export function nativeControlCreate(
host: ControlDirectiveHost,
parent: FormField<unknown>,
parseErrorsSource: WritableSignal<
Signal<readonly ValidationError.WithoutFieldTree[]> | undefined
>,
): () => void {
let updateMode = false;
const input = parent.nativeFormElement;
// TODO: (perf) ok to always create this?
const parseErrors = linkedSignal({
source: () => parent.state().value(),
computation: () => [] as readonly ValidationError.WithoutFieldTree[],
});
parseErrorsSource.set(parseErrors);
host.listenToDom('input', () => {
const state = parent.state();
state.controlValue.set(getNativeControlValue(input, state.value));
const {value, errors} = getNativeControlValue(input, state.value);
parseErrors.set(errors ?? []);
if (value !== undefined) {
state.controlValue.set(value);
}
});
host.listenToDom('blur', () => parent.state().markAsTouched());

View file

@ -147,7 +147,7 @@ export class FormField<T> {
private readonly config = inject(SIGNAL_FORMS_CONFIG, {optional: true});
private readonly parseErrorsSource = signal<
Signal<ValidationError.WithoutFieldTree[]> | undefined
Signal<readonly ValidationError.WithoutFieldTree[]> | undefined
>(undefined);
/** A lazily instantiated fake `NgControl`. */
@ -319,7 +319,11 @@ export class FormField<T> {
} else if (host.customControl) {
this.ɵngControlUpdate = customControlCreate(host, this as FormField<unknown>);
} else if (this.elementIsNativeFormElement) {
this.ɵngControlUpdate = nativeControlCreate(host, this as FormField<unknown>);
this.ɵngControlUpdate = nativeControlCreate(
host,
this as FormField<unknown>,
this.parseErrorsSource,
);
} else {
throw new RuntimeError(
RuntimeErrorCode.INVALID_FIELD_DIRECTIVE_HOST,

View file

@ -7,6 +7,7 @@
*/
import {type Renderer2, untracked} from '@angular/core';
import {NativeInputParseError, WithoutFieldTree} from '../api/rules';
/**
* Supported native control element types.
@ -48,6 +49,11 @@ export function isTextualFormElement(element: HTMLElement): boolean {
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA';
}
export interface NativeControlValue {
value?: unknown;
errors?: readonly WithoutFieldTree<NativeInputParseError>[];
}
/**
* Returns the value from a native control element.
*
@ -63,18 +69,24 @@ export function isTextualFormElement(element: HTMLElement): boolean {
export function getNativeControlValue(
element: NativeFormControl,
currentValue: () => unknown,
): unknown {
): NativeControlValue {
if (element.validity.badInput) {
return {
errors: [new NativeInputParseError() as WithoutFieldTree<NativeInputParseError>],
};
}
// Special cases for specific input types.
switch (element.type) {
case 'checkbox':
return element.checked;
return {value: element.checked};
case 'number':
case 'range':
case 'datetime-local':
// We can read a `number` or a `string` from this input type. Prefer whichever is consistent
// with the current type.
if (typeof untracked(currentValue) === 'number') {
return element.valueAsNumber;
return {value: element.valueAsNumber};
}
break;
case 'date':
@ -85,15 +97,15 @@ export function getNativeControlValue(
// is consistent with the current type.
const value = untracked(currentValue);
if (value === null || value instanceof Date) {
return element.valueAsDate;
return {value: element.valueAsDate};
} else if (typeof value === 'number') {
return element.valueAsNumber;
return {value: element.valueAsNumber};
}
break;
}
// Default to reading the value as a string.
return element.value;
return {value: element.value};
}
/**

View file

@ -0,0 +1,128 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {Component, signal, viewChildren} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {FormField, form} from '../../public_api';
describe('numeric inputs', () => {
describe('parsing logic', () => {
it('should not change the model when user enters un-parsable input', () => {
@Component({
imports: [FormField],
template: `<input type="number" [formField]="f" />`,
})
class TestCmp {
readonly data = signal<number>(42);
readonly f = form(this.data);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
patchNumberInput(input);
expect(input.value).toBe('42');
act(() => {
input.value = '42e';
input.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.f().value()).toBe(42);
expect(fixture.componentInstance.f().errors()).toEqual([
jasmine.objectContaining({kind: 'parse'}),
]);
act(() => {
input.value = '42e1';
input.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.f().value()).toBe(420);
expect(fixture.componentInstance.f().errors()).toEqual([]);
});
it('should clear parse errors on one control when another control for the same field updates the model', () => {
@Component({
imports: [FormField],
template: `
<input id="input1" type="number" [formField]="f" />
<input id="input2" type="number" [formField]="f" />
`,
})
class TestCmp {
readonly data = signal<number>(5);
readonly f = form(this.data);
readonly bindings = viewChildren(FormField);
}
const fixture = act(() => TestBed.createComponent(TestCmp));
const input1 = fixture.nativeElement.querySelector('#input1') as HTMLInputElement;
const input2 = fixture.nativeElement.querySelector('#input2') as HTMLInputElement;
patchNumberInput(input1);
patchNumberInput(input2);
expect(input1.value).toBe('5');
expect(input2.value).toBe('5');
// Trigger parse error on input1
act(() => {
input1.value = '5e';
input1.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.bindings()[0].errors()).toEqual([
jasmine.objectContaining({kind: 'parse'}),
]);
// Update model via input2
act(() => {
input2.value = '42';
input2.dispatchEvent(new Event('input'));
});
expect(fixture.componentInstance.bindings()[0].errors()).toEqual([]);
expect(fixture.componentInstance.data()).toBe(42);
expect(input1.value).toBe('42');
expect(input2.value).toBe('42');
});
});
});
function act<T>(fn: () => T): T {
try {
return fn();
} finally {
TestBed.tick();
}
}
/** Patch a number input to make its validity work as it would in a normal browser. */
function patchNumberInput(input: HTMLInputElement) {
let value = input.value;
Object.defineProperties(input, {
value: {
set: (v) => {
value = v;
},
get: () => {
const num = Number(value);
return Number.isNaN(num) ? '' : value;
},
},
valueAsNumber: {
get: () => Number(value),
set: (v) => {
value = String(v);
},
},
});
Object.defineProperties(input.validity, {
badInput: {get: () => Number.isNaN(Number(value))},
});
}