mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
27397b3f4f
commit
22afbb2f36
6 changed files with 196 additions and 17 deletions
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
128
packages/forms/signals/test/web/number_input.spec.ts
Normal file
128
packages/forms/signals/test/web/number_input.spec.ts
Normal 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))},
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue