Skip to content

Commit 22afbb2

Browse files
mmalerbamattrbeck
authored andcommitted
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
1 parent 27397b3 commit 22afbb2

File tree

6 files changed

+196
-17
lines changed

6 files changed

+196
-17
lines changed

goldens/public-api/forms/signals/index.api.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,11 +418,17 @@ export class MinValidationError extends BaseNgValidationError {
418418
readonly min: number;
419419
}
420420

421+
// @public
422+
export class NativeInputParseError extends BaseNgValidationError {
423+
// (undocumented)
424+
readonly kind = "parse";
425+
}
426+
421427
// @public
422428
export const NgValidationError: abstract new () => NgValidationError;
423429

424430
// @public (undocumented)
425-
export type NgValidationError = RequiredValidationError | MinValidationError | MaxValidationError | MinLengthValidationError | MaxLengthValidationError | PatternValidationError | EmailValidationError | StandardSchemaValidationError;
431+
export type NgValidationError = RequiredValidationError | MinValidationError | MaxValidationError | MinLengthValidationError | MaxLengthValidationError | PatternValidationError | EmailValidationError | StandardSchemaValidationError | NativeInputParseError;
426432

427433
// @public
428434
export type OneOrMany<T> = T | readonly T[];

packages/forms/signals/src/api/rules/validation/validation_errors.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {ValidationErrors} from '@angular/forms';
109
import type {FormField} from '../../../directive/form_field_directive';
1110
import type {FieldTree} from '../../types';
1211
import type {StandardSchemaValidationError} from './standard_schema';
@@ -453,6 +452,16 @@ export class EmailValidationError extends BaseNgValidationError {
453452
override readonly kind = 'email';
454453
}
455454

455+
/**
456+
* An error used to indicate that a value entered in a native input does not parse.
457+
*
458+
* @category validation
459+
* @experimental 21.2.0
460+
*/
461+
export class NativeInputParseError extends BaseNgValidationError {
462+
override readonly kind = 'parse';
463+
}
464+
456465
/**
457466
* The base class for all built-in, non-custom errors. This class can be used to check if an error
458467
* 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 =
487496
| MaxLengthValidationError
488497
| PatternValidationError
489498
| EmailValidationError
490-
| StandardSchemaValidationError;
499+
| StandardSchemaValidationError
500+
| NativeInputParseError;

packages/forms/signals/src/directive/control_native.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,47 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.dev/license
77
*/
8-
import type {ɵControlDirectiveHost as ControlDirectiveHost} from '@angular/core';
9-
import type {FormField} from './form_field_directive';
10-
import {getNativeControlValue, setNativeControlValue, setNativeDomProperty} from './native';
11-
import {observeSelectMutations} from './select';
8+
import {
9+
linkedSignal,
10+
type ɵControlDirectiveHost as ControlDirectiveHost,
11+
type Signal,
12+
type WritableSignal,
13+
} from '@angular/core';
14+
import type {ValidationError} from '../api/rules';
1215
import {
1316
bindingUpdated,
1417
CONTROL_BINDING_NAMES,
15-
type ControlBindingKey,
1618
createBindings,
1719
readFieldStateBindingValue,
20+
type ControlBindingKey,
1821
} from './bindings';
22+
import type {FormField} from './form_field_directive';
23+
import {getNativeControlValue, setNativeControlValue, setNativeDomProperty} from './native';
24+
import {observeSelectMutations} from './select';
1925

2026
export function nativeControlCreate(
2127
host: ControlDirectiveHost,
2228
parent: FormField<unknown>,
29+
parseErrorsSource: WritableSignal<
30+
Signal<readonly ValidationError.WithoutFieldTree[]> | undefined
31+
>,
2332
): () => void {
2433
let updateMode = false;
2534
const input = parent.nativeFormElement;
35+
// TODO: (perf) ok to always create this?
36+
const parseErrors = linkedSignal({
37+
source: () => parent.state().value(),
38+
computation: () => [] as readonly ValidationError.WithoutFieldTree[],
39+
});
40+
parseErrorsSource.set(parseErrors);
2641

2742
host.listenToDom('input', () => {
2843
const state = parent.state();
29-
state.controlValue.set(getNativeControlValue(input, state.value));
44+
const {value, errors} = getNativeControlValue(input, state.value);
45+
parseErrors.set(errors ?? []);
46+
if (value !== undefined) {
47+
state.controlValue.set(value);
48+
}
3049
});
3150

3251
host.listenToDom('blur', () => parent.state().markAsTouched());

packages/forms/signals/src/directive/form_field_directive.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export class FormField<T> {
147147
private readonly config = inject(SIGNAL_FORMS_CONFIG, {optional: true});
148148

149149
private readonly parseErrorsSource = signal<
150-
Signal<ValidationError.WithoutFieldTree[]> | undefined
150+
Signal<readonly ValidationError.WithoutFieldTree[]> | undefined
151151
>(undefined);
152152

153153
/** A lazily instantiated fake `NgControl`. */
@@ -319,7 +319,11 @@ export class FormField<T> {
319319
} else if (host.customControl) {
320320
this.ɵngControlUpdate = customControlCreate(host, this as FormField<unknown>);
321321
} else if (this.elementIsNativeFormElement) {
322-
this.ɵngControlUpdate = nativeControlCreate(host, this as FormField<unknown>);
322+
this.ɵngControlUpdate = nativeControlCreate(
323+
host,
324+
this as FormField<unknown>,
325+
this.parseErrorsSource,
326+
);
323327
} else {
324328
throw new RuntimeError(
325329
RuntimeErrorCode.INVALID_FIELD_DIRECTIVE_HOST,

packages/forms/signals/src/directive/native.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {type Renderer2, untracked} from '@angular/core';
10+
import {NativeInputParseError, WithoutFieldTree} from '../api/rules';
1011

1112
/**
1213
* Supported native control element types.
@@ -48,6 +49,11 @@ export function isTextualFormElement(element: HTMLElement): boolean {
4849
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA';
4950
}
5051

52+
export interface NativeControlValue {
53+
value?: unknown;
54+
errors?: readonly WithoutFieldTree<NativeInputParseError>[];
55+
}
56+
5157
/**
5258
* Returns the value from a native control element.
5359
*
@@ -63,18 +69,24 @@ export function isTextualFormElement(element: HTMLElement): boolean {
6369
export function getNativeControlValue(
6470
element: NativeFormControl,
6571
currentValue: () => unknown,
66-
): unknown {
72+
): NativeControlValue {
73+
if (element.validity.badInput) {
74+
return {
75+
errors: [new NativeInputParseError() as WithoutFieldTree<NativeInputParseError>],
76+
};
77+
}
78+
6779
// Special cases for specific input types.
6880
switch (element.type) {
6981
case 'checkbox':
70-
return element.checked;
82+
return {value: element.checked};
7183
case 'number':
7284
case 'range':
7385
case 'datetime-local':
7486
// We can read a `number` or a `string` from this input type. Prefer whichever is consistent
7587
// with the current type.
7688
if (typeof untracked(currentValue) === 'number') {
77-
return element.valueAsNumber;
89+
return {value: element.valueAsNumber};
7890
}
7991
break;
8092
case 'date':
@@ -85,15 +97,15 @@ export function getNativeControlValue(
8597
// is consistent with the current type.
8698
const value = untracked(currentValue);
8799
if (value === null || value instanceof Date) {
88-
return element.valueAsDate;
100+
return {value: element.valueAsDate};
89101
} else if (typeof value === 'number') {
90-
return element.valueAsNumber;
102+
return {value: element.valueAsNumber};
91103
}
92104
break;
93105
}
94106

95107
// Default to reading the value as a string.
96-
return element.value;
108+
return {value: element.value};
97109
}
98110

99111
/**
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {Component, signal, viewChildren} from '@angular/core';
10+
import {TestBed} from '@angular/core/testing';
11+
import {FormField, form} from '../../public_api';
12+
13+
describe('numeric inputs', () => {
14+
describe('parsing logic', () => {
15+
it('should not change the model when user enters un-parsable input', () => {
16+
@Component({
17+
imports: [FormField],
18+
template: `<input type="number" [formField]="f" />`,
19+
})
20+
class TestCmp {
21+
readonly data = signal<number>(42);
22+
readonly f = form(this.data);
23+
}
24+
25+
const fixture = act(() => TestBed.createComponent(TestCmp));
26+
const input = fixture.nativeElement.querySelector('input') as HTMLInputElement;
27+
patchNumberInput(input);
28+
29+
expect(input.value).toBe('42');
30+
31+
act(() => {
32+
input.value = '42e';
33+
input.dispatchEvent(new Event('input'));
34+
});
35+
36+
expect(fixture.componentInstance.f().value()).toBe(42);
37+
expect(fixture.componentInstance.f().errors()).toEqual([
38+
jasmine.objectContaining({kind: 'parse'}),
39+
]);
40+
41+
act(() => {
42+
input.value = '42e1';
43+
input.dispatchEvent(new Event('input'));
44+
});
45+
46+
expect(fixture.componentInstance.f().value()).toBe(420);
47+
expect(fixture.componentInstance.f().errors()).toEqual([]);
48+
});
49+
50+
it('should clear parse errors on one control when another control for the same field updates the model', () => {
51+
@Component({
52+
imports: [FormField],
53+
template: `
54+
<input id="input1" type="number" [formField]="f" />
55+
<input id="input2" type="number" [formField]="f" />
56+
`,
57+
})
58+
class TestCmp {
59+
readonly data = signal<number>(5);
60+
readonly f = form(this.data);
61+
readonly bindings = viewChildren(FormField);
62+
}
63+
64+
const fixture = act(() => TestBed.createComponent(TestCmp));
65+
const input1 = fixture.nativeElement.querySelector('#input1') as HTMLInputElement;
66+
const input2 = fixture.nativeElement.querySelector('#input2') as HTMLInputElement;
67+
patchNumberInput(input1);
68+
patchNumberInput(input2);
69+
70+
expect(input1.value).toBe('5');
71+
expect(input2.value).toBe('5');
72+
73+
// Trigger parse error on input1
74+
act(() => {
75+
input1.value = '5e';
76+
input1.dispatchEvent(new Event('input'));
77+
});
78+
79+
expect(fixture.componentInstance.bindings()[0].errors()).toEqual([
80+
jasmine.objectContaining({kind: 'parse'}),
81+
]);
82+
83+
// Update model via input2
84+
act(() => {
85+
input2.value = '42';
86+
input2.dispatchEvent(new Event('input'));
87+
});
88+
89+
expect(fixture.componentInstance.bindings()[0].errors()).toEqual([]);
90+
expect(fixture.componentInstance.data()).toBe(42);
91+
expect(input1.value).toBe('42');
92+
expect(input2.value).toBe('42');
93+
});
94+
});
95+
});
96+
97+
function act<T>(fn: () => T): T {
98+
try {
99+
return fn();
100+
} finally {
101+
TestBed.tick();
102+
}
103+
}
104+
105+
/** Patch a number input to make its validity work as it would in a normal browser. */
106+
function patchNumberInput(input: HTMLInputElement) {
107+
let value = input.value;
108+
Object.defineProperties(input, {
109+
value: {
110+
set: (v) => {
111+
value = v;
112+
},
113+
get: () => {
114+
const num = Number(value);
115+
return Number.isNaN(num) ? '' : value;
116+
},
117+
},
118+
valueAsNumber: {
119+
get: () => Number(value),
120+
set: (v) => {
121+
value = String(v);
122+
},
123+
},
124+
});
125+
Object.defineProperties(input.validity, {
126+
badInput: {get: () => Number.isNaN(Number(value))},
127+
});
128+
}

0 commit comments

Comments
 (0)