-
Notifications
You must be signed in to change notification settings - Fork 938
/
Copy pathon-report-validity.ts
401 lines (362 loc) · 13.1 KB
/
on-report-validity.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {LitElement, isServer} from 'lit';
import {ConstraintValidation} from './constraint-validation.js';
import {WithElementInternals, internals} from './element-internals.js';
import {MixinBase, MixinReturn} from './mixin.js';
/**
* A constraint validation element that has a callback for when the element
* should report validity styles and error messages to the user.
*
* This is commonly used in text-field-like controls that display error styles
* and error messages.
*/
export interface OnReportValidity extends ConstraintValidation {
/**
* A callback that is invoked when validity should be reported. Components
* that can display their own error state can use this and update their
* styles.
*
* If an invalid event is provided, the element is invalid. If `null`, the
* element is valid.
*
* The invalid event's `preventDefault()` may be called to stop the platform
* popup from displaying.
*
* @param invalidEvent The `invalid` event dispatched when an element is
* invalid, or `null` if the element is valid.
*/
[onReportValidity](invalidEvent: Event | null): void;
// `mixinOnReportValidity()` implements this optional method. If overriden,
// call `super.formAssociatedCallback(form)`.
// (inherit jsdoc from `FormAssociated`)
formAssociatedCallback(form: HTMLFormElement | null): void;
}
/**
* A symbol property used for a callback when validity has been reported.
*/
export const onReportValidity = Symbol('onReportValidity');
// Private symbol members, used to avoid name clashing.
const privateCleanupFormListeners = Symbol('privateCleanupFormListeners');
const privateDoNotReportInvalid = Symbol('privateDoNotReportInvalid');
const privateIsSelfReportingValidity = Symbol('privateIsSelfReportingValidity');
const privateCallOnReportValidity = Symbol('privateCallOnReportValidity');
/**
* Mixes in a callback for constraint validation when validity should be
* styled and reported to the user.
*
* This is commonly used in text-field-like controls that display error styles
* and error messages.
*
* @example
* ```ts
* const baseClass = mixinOnReportValidity(
* mixinConstraintValidation(
* mixinFormAssociated(mixinElementInternals(LitElement)),
* ),
* );
*
* class MyField extends baseClass {
* \@property({type: Boolean}) error = false;
* \@property() errorMessage = '';
*
* [onReportValidity](invalidEvent: Event | null) {
* this.error = !!invalidEvent;
* this.errorMessage = this.validationMessage;
*
* // Optionally prevent platform popup from displaying
* invalidEvent?.preventDefault();
* }
* }
* ```
*
* @param base The class to mix functionality into.
* @return The provided class with `OnReportValidity` mixed in.
*/
export function mixinOnReportValidity<
T extends MixinBase<LitElement & ConstraintValidation & WithElementInternals>,
>(base: T): MixinReturn<T, OnReportValidity> {
abstract class OnReportValidityElement
extends base
implements OnReportValidity
{
/**
* Used to clean up event listeners when a new form is associated.
*/
[privateCleanupFormListeners] = new AbortController();
/**
* Used to determine if an invalid event should report validity. Invalid
* events from `checkValidity()` do not trigger reporting.
*/
[privateDoNotReportInvalid] = false;
/**
* Used to determine if the control is reporting validity from itself, or
* if a `<form>` is causing the validity report. Forms have different
* control focusing behavior.
*/
[privateIsSelfReportingValidity] = false;
// Mixins must have a constructor with `...args: any[]`
// tslint:disable-next-line:no-any
constructor(...args: any[]) {
super(...args);
if (isServer) {
return;
}
this.addEventListener(
'invalid',
(invalidEvent) => {
// Listen for invalid events dispatched by a `<form>` when it tries to
// submit and the element is invalid. We ignore events dispatched when
// calling `checkValidity()` as well as untrusted events, since the
// `reportValidity()` and `<form>`-dispatched events are always
// trusted.
if (this[privateDoNotReportInvalid] || !invalidEvent.isTrusted) {
return;
}
this.addEventListener(
'invalid',
() => {
// A normal bubbling phase event listener. By adding it here, we
// ensure it's the last event listener that is called during the
// bubbling phase.
this[privateCallOnReportValidity](invalidEvent);
},
{once: true},
);
},
{
// Listen during the capture phase, which will happen before the
// bubbling phase. That way, we can add a final event listener that
// will run after other event listeners, and we can check if it was
// default prevented. This works because invalid does not bubble.
capture: true,
},
);
}
override checkValidity() {
this[privateDoNotReportInvalid] = true;
const valid = super.checkValidity();
this[privateDoNotReportInvalid] = false;
return valid;
}
override reportValidity() {
this[privateIsSelfReportingValidity] = true;
const valid = super.reportValidity();
// Constructor's invalid listener will handle reporting invalid events.
if (valid) {
this[privateCallOnReportValidity](null);
}
this[privateIsSelfReportingValidity] = false;
return valid;
}
[privateCallOnReportValidity](invalidEvent: Event | null) {
// Since invalid events do not bubble to parent listeners, and because
// our invalid listeners are added lazily after other listeners, we can
// reliably read `defaultPrevented` synchronously without worrying
// about waiting for another listener that could cancel it.
const wasCanceled = invalidEvent?.defaultPrevented;
if (wasCanceled) {
return;
}
this[onReportValidity](invalidEvent);
// If an implementation calls invalidEvent.preventDefault() to stop the
// platform popup from displaying, focusing is also prevented, so we need
// to manually focus.
const implementationCanceledFocus =
!wasCanceled && invalidEvent?.defaultPrevented;
if (!implementationCanceledFocus) {
return;
}
// The control should be focused when:
// - `control.reportValidity()` is called (self-reporting).
// - a form is reporting validity for its controls and this is the first
// invalid control.
if (
this[privateIsSelfReportingValidity] ||
isFirstInvalidControlInForm(this[internals].form, this)
) {
this.focus();
}
}
[onReportValidity](invalidEvent: Event | null) {
throw new Error('Implement [onReportValidity]');
}
override formAssociatedCallback(form: HTMLFormElement | null) {
// can't use super.formAssociatedCallback?.() due to closure
if (super.formAssociatedCallback) {
super.formAssociatedCallback(form);
}
// Clean up previous form listeners.
this[privateCleanupFormListeners].abort();
if (!form) {
return;
}
this[privateCleanupFormListeners] = new AbortController();
// Add a listener that fires when the form runs constraint validation and
// the control is valid, so that it may remove its error styles.
//
// This happens on `form.reportValidity()` and `form.requestSubmit()`
// (both when the submit fails and passes).
addFormReportValidListener(
this,
form,
() => {
this[privateCallOnReportValidity](null);
},
this[privateCleanupFormListeners].signal,
);
}
}
return OnReportValidityElement;
}
/**
* Add a listener that fires when a form runs constraint validation on a control
* and it is valid. This is needed to clear previously invalid styles.
*
* @param control The control of the form to listen for valid events.
* @param form The control's form that can run constraint validation.
* @param onControlValid A listener that is called when the form runs constraint
* validation and the control is valid.
* @param cleanup A cleanup signal to remove the listener.
*/
function addFormReportValidListener(
control: Element,
form: HTMLFormElement,
onControlValid: () => void,
cleanup: AbortSignal,
) {
const validateHooks = getFormValidateHooks(form);
// When a form validates its controls, check if an invalid event is dispatched
// on the control. If it is not, then inform the control to report its valid
// state.
let controlFiredInvalid = false;
let cleanupInvalidListener: AbortController | undefined;
let isNextSubmitFromHook = false;
validateHooks.addEventListener(
'before',
() => {
isNextSubmitFromHook = true;
cleanupInvalidListener = new AbortController();
controlFiredInvalid = false;
control.addEventListener(
'invalid',
() => {
controlFiredInvalid = true;
},
{
signal: cleanupInvalidListener.signal,
},
);
},
{signal: cleanup},
);
validateHooks.addEventListener(
'after',
() => {
isNextSubmitFromHook = false;
cleanupInvalidListener?.abort();
if (controlFiredInvalid) {
return;
}
onControlValid();
},
{signal: cleanup},
);
// The above hooks handle imperatively submitting the form, but not
// declaratively submitting the form. This happens when:
// 1. A non-custom element `<button type="submit">` is clicked.
// 2. Enter is pressed on a non-custom element text editable `<input>`.
form.addEventListener(
'submit',
() => {
// This submit was from `form.requestSubmit()`, which already calls the
// listener.
if (isNextSubmitFromHook) {
return;
}
onControlValid();
},
{
signal: cleanup,
},
);
// Note: it is a known limitation that we cannot detect if a form tries to
// submit declaratively, but fails to do so because an unrelated sibling
// control failed its constraint validation.
//
// Since we cannot detect when that happens, a previously invalid control may
// not clear its error styling when it becomes valid again.
//
// To work around this, call `form.reportValidity()` when submitting a form
// declaratively. This can be down on the `<button type="submit">`'s click or
// the text editable `<input>`'s 'Enter' keydown.
}
const FORM_VALIDATE_HOOKS = new WeakMap<HTMLFormElement, EventTarget>();
/**
* Get a hooks `EventTarget` that dispatches 'before' and 'after' events that
* fire before a form runs constraint validation and immediately after it
* finishes running constraint validation on its controls.
*
* This happens during `form.reportValidity()` and `form.requestSubmit()`.
*
* @param form The form to get or set up hooks for.
* @return A hooks `EventTarget` to add listeners to.
*/
function getFormValidateHooks(form: HTMLFormElement) {
if (!FORM_VALIDATE_HOOKS.has(form)) {
// Patch form methods to add event listener hooks. These are needed to react
// to form behaviors that do not dispatch events, such as a form asking its
// controls to report their validity.
//
// We should only patch the methods once, since multiple controls and other
// forces may want to patch this method. We cannot reliably clean it up if
// there are multiple patched and re-patched methods referring holding
// references to each other.
//
// Instead, we never clean up the patch but add and clean up event listeners
// added to the hooks after the patch.
const hooks = new EventTarget();
FORM_VALIDATE_HOOKS.set(form, hooks);
// Add hooks to support notifying before and after a form has run constraint
// validation on its controls.
// Note: `form.submit()` does not run constraint validation per spec.
for (const methodName of ['reportValidity', 'requestSubmit'] as const) {
const superMethod = form[methodName];
form[methodName] = function (this: HTMLFormElement) {
hooks.dispatchEvent(new Event('before'));
const result = Reflect.apply(superMethod, this, arguments);
hooks.dispatchEvent(new Event('after'));
return result;
};
}
}
return FORM_VALIDATE_HOOKS.get(form)!;
}
/**
* Checks if a control is the first invalid control in a form.
*
* @param form The control's form. When `null`, the control doesn't have a form
* and the method returns true.
* @param control The control to check.
* @return True if there is no form or if the control is the form's first
* invalid control.
*/
function isFirstInvalidControlInForm(
form: HTMLFormElement | null,
control: HTMLElement,
) {
if (!form) {
return true;
}
let firstInvalidControl: Element | undefined;
for (const element of form.elements) {
if (element.matches(':invalid')) {
firstInvalidControl = element;
break;
}
}
return firstInvalidControl === control;
}