Skip to content

Commit 21432b3

Browse files
asynclizcopybara-github
authored andcommitted
chore(behaviors): add mixinOnReportValidity for text field validation styling
PiperOrigin-RevId: 585698467
1 parent 6be83b4 commit 21432b3

File tree

3 files changed

+386
-2
lines changed

3 files changed

+386
-2
lines changed

labs/behaviors/constraint-validation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {Validator} from './validators/validator.js';
1616
*
1717
* https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation
1818
*/
19-
export interface ConstraintValidation {
19+
export interface ConstraintValidation extends FormAssociated {
2020
/**
2121
* Returns a ValidityState object that represents the validity states of the
2222
* element.
@@ -117,7 +117,7 @@ const privateSyncValidity = Symbol('privateSyncValidity');
117117
const privateCustomValidationMessage = Symbol('privateCustomValidationMessage');
118118

119119
/**
120-
* Mixins in constraint validation APIs for an element.
120+
* Mixes in constraint validation APIs for an element.
121121
*
122122
* See https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation
123123
* for more details.

labs/behaviors/on-report-validity.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {LitElement} from 'lit';
8+
9+
import {ConstraintValidation} from './constraint-validation.js';
10+
import {MixinBase, MixinReturn} from './mixin.js';
11+
12+
/**
13+
* A constraint validation element that has a callback for when the element
14+
* should report validity styles and error messages to the user.
15+
*
16+
* This is commonly used in text-field-like controls that display error styles
17+
* and error messages.
18+
*/
19+
export interface OnReportValidity extends ConstraintValidation {
20+
/**
21+
* A callback that is invoked when validity should be reported. Components
22+
* that can display their own error state can use this and update their
23+
* styles.
24+
*
25+
* If an invalid event is provided, the element is invalid. If `null`, the
26+
* element is valid.
27+
*
28+
* The invalid event's `preventDefault()` may be called to stop the platform
29+
* popup from displaying.
30+
*
31+
* @param invalidEvent The `invalid` event dispatched when an element is
32+
* invalid, or `null` if the element is valid.
33+
*/
34+
[onReportValidity](invalidEvent: Event | null): void;
35+
36+
// `mixinOnReportValidity()` implements this optional method. If overriden,
37+
// call `super.formAssociatedCallback(form)`.
38+
// (inherit jsdoc from `FormAssociated`)
39+
formAssociatedCallback(form: HTMLFormElement | null): void;
40+
}
41+
42+
/**
43+
* A symbol property used for a callback when validity has been reported.
44+
*/
45+
export const onReportValidity = Symbol('onReportValidity');
46+
47+
// Private symbol members, used to avoid name clashing.
48+
const privateCleanupFormListeners = Symbol('privateCleanupFormListeners');
49+
50+
/**
51+
* Mixes in a callback for constraint validation when validity should be
52+
* styled and reported to the user.
53+
*
54+
* This is commonly used in text-field-like controls that display error styles
55+
* and error messages.
56+
*
57+
* @example
58+
* ```ts
59+
* const baseClass = mixinOnReportValidity(
60+
* mixinConstraintValidation(
61+
* mixinFormAssociated(mixinElementInternals(LitElement)),
62+
* ),
63+
* );
64+
*
65+
* class MyField extends baseClass {
66+
* \@property({type: Boolean}) error = false;
67+
* \@property() errorMessage = '';
68+
*
69+
* [onReportValidity](invalidEvent: Event | null) {
70+
* this.error = !!invalidEvent;
71+
* this.errorMessage = this.validationMessage;
72+
*
73+
* // Optionally prevent platform popup from displaying
74+
* invalidEvent?.preventDefault();
75+
* }
76+
* }
77+
* ```
78+
*
79+
* @param base The class to mix functionality into.
80+
* @return The provided class with `OnReportValidity` mixed in.
81+
*/
82+
export function mixinOnReportValidity<
83+
T extends MixinBase<LitElement & ConstraintValidation>,
84+
>(base: T): MixinReturn<T, OnReportValidity> {
85+
abstract class OnReportValidityElement
86+
extends base
87+
implements OnReportValidity
88+
{
89+
/**
90+
* Used to clean up event listeners when a new form is associated.
91+
*/
92+
[privateCleanupFormListeners] = new AbortController();
93+
94+
override reportValidity() {
95+
let invalidEvent = null as Event | null;
96+
const cleanupInvalidListener = new AbortController();
97+
this.addEventListener(
98+
'invalid',
99+
(event) => {
100+
invalidEvent = event;
101+
},
102+
{signal: cleanupInvalidListener.signal},
103+
);
104+
105+
const valid = super.reportValidity();
106+
cleanupInvalidListener.abort();
107+
// event may be null, so check for strict `true`. If null it should still
108+
// be reported.
109+
if (invalidEvent?.defaultPrevented !== true) {
110+
this[onReportValidity](invalidEvent);
111+
}
112+
113+
return valid;
114+
}
115+
116+
[onReportValidity](invalidEvent: Event | null) {
117+
throw new Error('Implement [onReportValidity]');
118+
}
119+
120+
override formAssociatedCallback(form: HTMLFormElement | null) {
121+
// can't use super.formAssociatedCallback?.() due to closure
122+
if (super.formAssociatedCallback) {
123+
super.formAssociatedCallback(form);
124+
}
125+
126+
// Clean up previous submit listener
127+
this[privateCleanupFormListeners].abort();
128+
if (!form) {
129+
return;
130+
}
131+
132+
this[privateCleanupFormListeners] = new AbortController();
133+
// If the element's form submits, then all controls are valid. This lets
134+
// the element remove its error styles that may have been set when
135+
// `reportValidity()` was called.
136+
form.addEventListener(
137+
'submit',
138+
() => {
139+
this[onReportValidity](null);
140+
},
141+
{
142+
signal: this[privateCleanupFormListeners].signal,
143+
},
144+
);
145+
146+
// Inject a callback when `form.reportValidity()` is called and the form
147+
// is valid. There isn't an event that is dispatched to alert us (unlike
148+
// the 'invalid' event), and we need to remove error styles when
149+
// `form.reportValidity()` is called and returns true.
150+
let reportedInvalidEventFromForm = false;
151+
let formReportValidityCleanup = new AbortController();
152+
injectFormReportValidityHooks({
153+
form,
154+
cleanup: this[privateCleanupFormListeners].signal,
155+
beforeReportValidity: () => {
156+
reportedInvalidEventFromForm = false;
157+
this.addEventListener(
158+
'invalid',
159+
(invalidEvent) => {
160+
reportedInvalidEventFromForm = true;
161+
if (!invalidEvent.defaultPrevented) {
162+
this[onReportValidity](invalidEvent);
163+
}
164+
},
165+
{signal: formReportValidityCleanup.signal},
166+
);
167+
},
168+
afterReportValidity: () => {
169+
formReportValidityCleanup.abort();
170+
formReportValidityCleanup = new AbortController();
171+
if (reportedInvalidEventFromForm) {
172+
reportedInvalidEventFromForm = false;
173+
return;
174+
}
175+
176+
// Report successful form validation if an invalid event wasn't
177+
// fired.
178+
this[onReportValidity](null);
179+
},
180+
});
181+
}
182+
}
183+
184+
return OnReportValidityElement;
185+
}
186+
187+
const FORM_REPORT_VALIDITY_HOOKS = new WeakMap<HTMLFormElement, EventTarget>();
188+
189+
function injectFormReportValidityHooks({
190+
form,
191+
beforeReportValidity,
192+
afterReportValidity,
193+
cleanup,
194+
}: {
195+
form: HTMLFormElement;
196+
beforeReportValidity: () => void;
197+
afterReportValidity: () => void;
198+
cleanup: AbortSignal;
199+
}) {
200+
if (!FORM_REPORT_VALIDITY_HOOKS.has(form)) {
201+
// Patch form.reportValidity() to add an event target that can be used to
202+
// react when the method is called.
203+
// We should only patch this method once, since multiple controls and other
204+
// forces may want to patch this method. We cannot reliably clean it up by
205+
// resetting the method to "superReportValidity", which may be a patched
206+
// function.
207+
// Instead, we never clean up the patch but add and clean up event listener
208+
// hooks once it's patched.
209+
const hooks = new EventTarget();
210+
const superReportValidity = form.reportValidity;
211+
form.reportValidity = function (this: HTMLFormElement) {
212+
hooks.dispatchEvent(new Event('before'));
213+
const valid = superReportValidity.call(this);
214+
hooks.dispatchEvent(new Event('after'));
215+
return valid;
216+
};
217+
218+
FORM_REPORT_VALIDITY_HOOKS.set(form, hooks);
219+
}
220+
221+
const hooks = FORM_REPORT_VALIDITY_HOOKS.get(form)!;
222+
hooks.addEventListener('before', beforeReportValidity, {signal: cleanup});
223+
hooks.addEventListener('after', afterReportValidity, {signal: cleanup});
224+
}

0 commit comments

Comments
 (0)