Skip to content

Commit df8b020

Browse files
alxhubleonsenft
authored andcommitted
fix(forms): clear native date inputs correctly in signal forms when changed via native UI
When a native date input gets cleared manually by a user via the internal browser UI, the element changes from invalid to valid, but no `input` event is emitted. This commit introduces `InputValidityMonitor`, an injectable service that intercepts these edge-case native status changes. The monitor dynamically installs CSP-compliant styles appending specific animation keyframes for `:valid` and `:invalid` pseudoclasses on native form controls. By attaching an `animationstart` listener, Angular intercepts these changes immediately and re-invokes the parser. Fixes #67300
1 parent 98c5afd commit df8b020

File tree

9 files changed

+554
-52
lines changed

9 files changed

+554
-52
lines changed

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ import {
2020
type ControlBindingKey,
2121
} from './bindings';
2222
import type {FormField} from './form_field_directive';
23-
import {getNativeControlValue, setNativeControlValue, setNativeDomProperty} from './native';
23+
import {InputValidityMonitor} from './input_validity_monitor';
24+
import {
25+
getNativeControlValue,
26+
isInput,
27+
inputRequiresValidityTracking,
28+
setNativeControlValue,
29+
setNativeDomProperty,
30+
} from './native';
2431
import {observeSelectMutations} from './select';
2532

2633
export function nativeControlCreate(
@@ -29,6 +36,7 @@ export function nativeControlCreate(
2936
parseErrorsSource: WritableSignal<
3037
Signal<readonly ValidationError.WithoutFieldTree[]> | undefined
3138
>,
39+
validityMonitor: InputValidityMonitor,
3240
): () => void {
3341
let updateMode = false;
3442
const input = parent.nativeFormElement;
@@ -41,14 +49,19 @@ export function nativeControlCreate(
4149
(rawValue: unknown) => parent.state().controlValue.set(rawValue),
4250
// Our parse function doesn't care about the raw value that gets passed in,
4351
// It just reads the newly parsed value directly off the input element.
44-
() => getNativeControlValue(input, parent.state().value),
52+
(_rawValue: unknown) => getNativeControlValue(input, parent.state().value, validityMonitor),
4553
);
4654

4755
parseErrorsSource.set(parser.errors);
4856
// Pass undefined as the raw value since the parse function doesn't care about it.
4957
host.listenToDom('input', () => parser.setRawValue(undefined));
5058
host.listenToDom('blur', () => parent.state().markAsTouched());
5159

60+
// TODO: move extraction to first update pass?
61+
if (isInput(input) && inputRequiresValidityTracking(input)) {
62+
validityMonitor.watchValidity(input, () => parser.setRawValue(undefined));
63+
}
64+
5265
parent.registerAsBinding();
5366

5467
// The native `<select>` tracks its `value` by keeping track of the selected `<option>`.

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
isTextualFormElement,
4848
type NativeFormControl,
4949
} from './native';
50+
import {InputValidityMonitor} from './input_validity_monitor';
5051

5152
export const ɵNgFieldDirective: unique symbol = Symbol();
5253

@@ -152,6 +153,7 @@ export class FormField<T> {
152153
private readonly controlValueAccessors = inject(NG_VALUE_ACCESSOR, {optional: true, self: true});
153154

154155
private readonly config = inject(SIGNAL_FORMS_CONFIG, {optional: true});
156+
private readonly validityMonitor = inject(InputValidityMonitor);
155157

156158
private readonly parseErrorsSource = signal<
157159
Signal<readonly ValidationError.WithoutFieldTree[]> | undefined
@@ -329,6 +331,7 @@ export class FormField<T> {
329331
host,
330332
this as FormField<unknown>,
331333
this.parseErrorsSource,
334+
this.validityMonitor,
332335
);
333336
} else {
334337
throw new RuntimeError(
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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 {DOCUMENT, isPlatformBrowser} from '@angular/common';
10+
import {Injectable, CSP_NONCE, inject, OnDestroy, PLATFORM_ID, forwardRef} from '@angular/core';
11+
12+
/**
13+
* Service that monitors validity state changes on native form elements.
14+
*
15+
* It works by dynamically installing a CSS transition on `input, textarea` `:valid`
16+
* and `:invalid` states, which allows us to intercept a `transitionstart` event
17+
* whenever the native validity state changes without an `input` event (e.g. clearing a date input).
18+
*/
19+
@Injectable({providedIn: 'root', useClass: forwardRef(() => AnimationInputValidityMonitor)})
20+
export abstract class InputValidityMonitor {
21+
abstract watchValidity(element: HTMLInputElement, callback: () => void): void;
22+
abstract isBadInput(element: HTMLInputElement): boolean;
23+
}
24+
25+
@Injectable()
26+
export class AnimationInputValidityMonitor extends InputValidityMonitor implements OnDestroy {
27+
private readonly document = inject(DOCUMENT);
28+
private readonly cspNonce = inject(CSP_NONCE, {optional: true});
29+
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
30+
private readonly injectedStyles = new WeakMap<Document | ShadowRoot, HTMLStyleElement>();
31+
32+
/** Starts watching the given element for validity state changes. */
33+
override watchValidity(element: HTMLInputElement, callback: () => void): void {
34+
if (!this.isBrowser) {
35+
return;
36+
}
37+
38+
const rootNode = element.getRootNode() as Document | ShadowRoot;
39+
if (!this.injectedStyles.has(rootNode)) {
40+
this.injectedStyles.set(rootNode, this.createTransitionStyle(rootNode));
41+
}
42+
43+
element.addEventListener('animationstart', (event: Event) => {
44+
const animationEvent = event as AnimationEvent;
45+
if (
46+
animationEvent.animationName === 'ng-valid' ||
47+
animationEvent.animationName === 'ng-invalid'
48+
) {
49+
callback();
50+
}
51+
});
52+
}
53+
54+
override isBadInput(element: HTMLInputElement): boolean {
55+
return element.validity?.badInput ?? false;
56+
}
57+
58+
private createTransitionStyle(rootNode: Document | ShadowRoot): HTMLStyleElement {
59+
const element = this.document.createElement('style');
60+
if (this.cspNonce) {
61+
element.nonce = this.cspNonce;
62+
}
63+
element.textContent = `
64+
@keyframes ng-valid {}
65+
@keyframes ng-invalid {}
66+
input:valid, textarea:valid {
67+
animation: ng-valid 0.001s;
68+
}
69+
input:invalid, textarea:invalid {
70+
animation: ng-invalid 0.001s;
71+
}
72+
`;
73+
if (rootNode.nodeType === 9 /* Node.DOCUMENT_NODE */) {
74+
(rootNode as Document).head?.appendChild(element);
75+
} else {
76+
rootNode.appendChild(element);
77+
}
78+
return element;
79+
}
80+
81+
ngOnDestroy(): void {
82+
// We explicitly clean up the main document's injected style wrapper.
83+
this.injectedStyles.get(this.document)?.remove();
84+
85+
// We do not need to iterate over ShadowRoots to clean them up.
86+
// The WeakMap drops the reference when the ShadowRoot is destroyed,
87+
// and the DOM subtree takes care of its own garbage collection.
88+
}
89+
}

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {untracked} from '@angular/core';
1010
import {NativeInputParseError, WithoutFieldTree} from '../api/rules';
1111
import type {ParseResult} from '../api/transformed_value';
12+
import type {InputValidityMonitor} from './input_validity_monitor';
1213

1314
// Re-export shared native utilities from main forms package
1415
export {
@@ -36,10 +37,11 @@ import type {ɵNativeFormControl as NativeFormControl} from '@angular/forms';
3637
export function getNativeControlValue(
3738
element: NativeFormControl,
3839
currentValue: () => unknown,
40+
validityMonitor: InputValidityMonitor,
3941
): ParseResult<unknown> {
4042
let modelValue: unknown;
4143

42-
if (element.validity.badInput) {
44+
if (isInput(element) && validityMonitor.isBadInput(element)) {
4345
return {
4446
error: new NativeInputParseError() as WithoutFieldTree<NativeInputParseError>,
4547
};
@@ -162,3 +164,16 @@ export function setNativeNumberControlValue(element: HTMLInputElement, value: nu
162164
element.valueAsNumber = value;
163165
}
164166
}
167+
export function isInput(element: HTMLElement): element is HTMLInputElement {
168+
return element.tagName === 'INPUT';
169+
}
170+
171+
export function inputRequiresValidityTracking(input: HTMLInputElement): boolean {
172+
return (
173+
input.type === 'date' ||
174+
input.type === 'datetime-local' ||
175+
input.type === 'month' ||
176+
input.type === 'time' ||
177+
input.type === 'week'
178+
);
179+
}

packages/forms/signals/test/web/BUILD.bazel

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ load("//tools:defaults.bzl", "ng_project", "zoneless_web_test_suite")
33
ng_project(
44
name = "test_lib",
55
testonly = True,
6-
srcs = glob(["**/*.spec.ts"]),
6+
srcs = glob([
7+
"**/*.spec.ts",
8+
"**/*.ts",
9+
]),
710
deps = [
811
"//:node_modules/zod",
912
"//packages/core",

0 commit comments

Comments
 (0)