From 22afbb2f36be89c2ae575df343571a918dec5985 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Sat, 7 Feb 2026 10:01:47 -0800 Subject: [PATCH] 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 --- goldens/public-api/forms/signals/index.api.md | 8 +- .../api/rules/validation/validation_errors.ts | 14 +- .../signals/src/directive/control_native.ts | 31 ++++- .../src/directive/form_field_directive.ts | 8 +- .../forms/signals/src/directive/native.ts | 24 +++- .../signals/test/web/number_input.spec.ts | 128 ++++++++++++++++++ 6 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 packages/forms/signals/test/web/number_input.spec.ts diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 0084d2a9ffe..4a80de84919 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -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 | readonly T[]; diff --git a/packages/forms/signals/src/api/rules/validation/validation_errors.ts b/packages/forms/signals/src/api/rules/validation/validation_errors.ts index 022e2dcf9d0..c16309feaaa 100644 --- a/packages/forms/signals/src/api/rules/validation/validation_errors.ts +++ b/packages/forms/signals/src/api/rules/validation/validation_errors.ts @@ -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; diff --git a/packages/forms/signals/src/directive/control_native.ts b/packages/forms/signals/src/directive/control_native.ts index d06f67804d9..63ec25eaa27 100644 --- a/packages/forms/signals/src/directive/control_native.ts +++ b/packages/forms/signals/src/directive/control_native.ts @@ -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, + parseErrorsSource: WritableSignal< + Signal | 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()); diff --git a/packages/forms/signals/src/directive/form_field_directive.ts b/packages/forms/signals/src/directive/form_field_directive.ts index 04f3bbfb94f..a606ce0ed3a 100644 --- a/packages/forms/signals/src/directive/form_field_directive.ts +++ b/packages/forms/signals/src/directive/form_field_directive.ts @@ -147,7 +147,7 @@ export class FormField { private readonly config = inject(SIGNAL_FORMS_CONFIG, {optional: true}); private readonly parseErrorsSource = signal< - Signal | undefined + Signal | undefined >(undefined); /** A lazily instantiated fake `NgControl`. */ @@ -319,7 +319,11 @@ export class FormField { } else if (host.customControl) { this.ɵngControlUpdate = customControlCreate(host, this as FormField); } else if (this.elementIsNativeFormElement) { - this.ɵngControlUpdate = nativeControlCreate(host, this as FormField); + this.ɵngControlUpdate = nativeControlCreate( + host, + this as FormField, + this.parseErrorsSource, + ); } else { throw new RuntimeError( RuntimeErrorCode.INVALID_FIELD_DIRECTIVE_HOST, diff --git a/packages/forms/signals/src/directive/native.ts b/packages/forms/signals/src/directive/native.ts index b9f8c8bd3ee..a057569342d 100644 --- a/packages/forms/signals/src/directive/native.ts +++ b/packages/forms/signals/src/directive/native.ts @@ -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[]; +} + /** * 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], + }; + } + // 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}; } /** diff --git a/packages/forms/signals/test/web/number_input.spec.ts b/packages/forms/signals/test/web/number_input.spec.ts new file mode 100644 index 00000000000..ad5a02d77e6 --- /dev/null +++ b/packages/forms/signals/test/web/number_input.spec.ts @@ -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: ``, + }) + class TestCmp { + readonly data = signal(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: ` + + + `, + }) + class TestCmp { + readonly data = signal(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(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))}, + }); +}