@@ -168,101 +168,163 @@ export function mixinOnReportValidity<
168
168
super . formAssociatedCallback ( form ) ;
169
169
}
170
170
171
- // Clean up previous submit listener
171
+ // Clean up previous form listeners.
172
172
this [ privateCleanupFormListeners ] . abort ( ) ;
173
173
if ( ! form ) {
174
174
return ;
175
175
}
176
176
177
177
this [ privateCleanupFormListeners ] = new AbortController ( ) ;
178
- // If the element's form submits, then all controls are valid. This lets
179
- // the element remove its error styles that may have been set when
180
- // `reportValidity()` was called.
181
- form . addEventListener (
182
- 'submit' ,
178
+
179
+ // Add a listener that fires when the form runs constraint validation and
180
+ // the control is valid, so that it may remove its error styles.
181
+ //
182
+ // This happens on `form.reportValidity()` and `form.requestSubmit()`
183
+ // (both when the submit fails and passes).
184
+ addFormReportValidListener (
185
+ this ,
186
+ form ,
183
187
( ) => {
184
188
this [ onReportValidity ] ( null ) ;
185
189
} ,
190
+ this [ privateCleanupFormListeners ] . signal ,
191
+ ) ;
192
+ }
193
+ }
194
+
195
+ return OnReportValidityElement ;
196
+ }
197
+
198
+ /**
199
+ * Add a listener that fires when a form runs constraint validation on a control
200
+ * and it is valid. This is needed to clear previously invalid styles.
201
+ *
202
+ * @param control The control of the form to listen for valid events.
203
+ * @param form The control's form that can run constraint validation.
204
+ * @param onControlValid A listener that is called when the form runs constraint
205
+ * validation and the control is valid.
206
+ * @param cleanup A cleanup signal to remove the listener.
207
+ */
208
+ function addFormReportValidListener (
209
+ control : Element ,
210
+ form : HTMLFormElement ,
211
+ onControlValid : ( ) => void ,
212
+ cleanup : AbortSignal ,
213
+ ) {
214
+ const validateHooks = getFormValidateHooks ( form ) ;
215
+
216
+ // When a form validates its controls, check if an invalid event is dispatched
217
+ // on the control. If it is not, then inform the control to report its valid
218
+ // state.
219
+ let controlFiredInvalid = false ;
220
+ let cleanupInvalidListener : AbortController | undefined ;
221
+ let isNextSubmitFromHook = false ;
222
+ validateHooks . addEventListener (
223
+ 'before' ,
224
+ ( ) => {
225
+ isNextSubmitFromHook = true ;
226
+ cleanupInvalidListener = new AbortController ( ) ;
227
+ controlFiredInvalid = false ;
228
+ control . addEventListener (
229
+ 'invalid' ,
230
+ ( ) => {
231
+ controlFiredInvalid = true ;
232
+ } ,
186
233
{
187
- signal : this [ privateCleanupFormListeners ] . signal ,
234
+ signal : cleanupInvalidListener . signal ,
188
235
} ,
189
236
) ;
237
+ } ,
238
+ { signal : cleanup } ,
239
+ ) ;
190
240
191
- // Inject a callback when `form.reportValidity()` is called and the form
192
- // is valid. There isn't an event that is dispatched to alert us (unlike
193
- // the 'invalid' event), and we need to remove error styles when
194
- // `form.reportValidity()` is called and returns true.
195
- let reportedInvalidEventFromForm = false ;
196
- let formReportValidityCleanup = new AbortController ( ) ;
197
- injectFormReportValidityHooks ( {
198
- form,
199
- cleanup : this [ privateCleanupFormListeners ] . signal ,
200
- beforeReportValidity : ( ) => {
201
- reportedInvalidEventFromForm = false ;
202
- this . addEventListener (
203
- 'invalid' ,
204
- ( ) => {
205
- reportedInvalidEventFromForm = true ;
206
- // Constructor's invalid listener will handle reporting invalid
207
- // events.
208
- } ,
209
- { signal : formReportValidityCleanup . signal } ,
210
- ) ;
211
- } ,
212
- afterReportValidity : ( ) => {
213
- formReportValidityCleanup . abort ( ) ;
214
- formReportValidityCleanup = new AbortController ( ) ;
215
- if ( reportedInvalidEventFromForm ) {
216
- reportedInvalidEventFromForm = false ;
217
- return ;
218
- }
241
+ validateHooks . addEventListener (
242
+ 'after' ,
243
+ ( ) => {
244
+ isNextSubmitFromHook = false ;
245
+ cleanupInvalidListener ?. abort ( ) ;
246
+ if ( controlFiredInvalid ) {
247
+ return ;
248
+ }
219
249
220
- // Report successful form validation if an invalid event wasn't
221
- // fired.
222
- this [ onReportValidity ] ( null ) ;
223
- } ,
224
- } ) ;
225
- }
226
- }
250
+ onControlValid ( ) ;
251
+ } ,
252
+ { signal : cleanup } ,
253
+ ) ;
227
254
228
- return OnReportValidityElement ;
255
+ // The above hooks handle imperatively submitting the form, but not
256
+ // declaratively submitting the form. This happens when:
257
+ // 1. A non-custom element `<button type="submit">` is clicked.
258
+ // 2. Enter is pressed on a non-custom element text editable `<input>`.
259
+ form . addEventListener (
260
+ 'submit' ,
261
+ ( ) => {
262
+ // This submit was from `form.requestSubmit()`, which already calls the
263
+ // listener.
264
+ if ( isNextSubmitFromHook ) {
265
+ return ;
266
+ }
267
+
268
+ onControlValid ( ) ;
269
+ } ,
270
+ {
271
+ signal : cleanup ,
272
+ } ,
273
+ ) ;
274
+
275
+ // Note: it is a known limitation that we cannot detect if a form tries to
276
+ // submit declaratively, but fails to do so because an unrelated sibling
277
+ // control failed its constraint validation.
278
+ //
279
+ // Since we cannot detect when that happens, a previously invalid control may
280
+ // not clear its error styling when it becomes valid again.
281
+ //
282
+ // To work around this, call `form.reportValidity()` when submitting a form
283
+ // declaratively. This can be down on the `<button type="submit">`'s click or
284
+ // the text editable `<input>`'s 'Enter' keydown.
229
285
}
230
286
231
- const FORM_REPORT_VALIDITY_HOOKS = new WeakMap < HTMLFormElement , EventTarget > ( ) ;
287
+ const FORM_VALIDATE_HOOKS = new WeakMap < HTMLFormElement , EventTarget > ( ) ;
232
288
233
- function injectFormReportValidityHooks ( {
234
- form,
235
- beforeReportValidity,
236
- afterReportValidity,
237
- cleanup,
238
- } : {
239
- form : HTMLFormElement ;
240
- beforeReportValidity : ( ) => void ;
241
- afterReportValidity : ( ) => void ;
242
- cleanup : AbortSignal ;
243
- } ) {
244
- if ( ! FORM_REPORT_VALIDITY_HOOKS . has ( form ) ) {
245
- // Patch form.reportValidity() to add an event target that can be used to
246
- // react when the method is called.
247
- // We should only patch this method once, since multiple controls and other
248
- // forces may want to patch this method. We cannot reliably clean it up by
249
- // resetting the method to "superReportValidity", which may be a patched
250
- // function.
251
- // Instead, we never clean up the patch but add and clean up event listener
252
- // hooks once it's patched.
289
+ /**
290
+ * Get a hooks `EventTarget` that dispatches 'before' and 'after' events that
291
+ * fire before a form runs constraint validation and immediately after it
292
+ * finishes running constraint validation on its controls.
293
+ *
294
+ * This happens during `form.reportValidity()` and `form.requestSubmit()`.
295
+ *
296
+ * @param form The form to get or set up hooks for.
297
+ * @return A hooks `EventTarget` to add listeners to.
298
+ */
299
+ function getFormValidateHooks ( form : HTMLFormElement ) {
300
+ if ( ! FORM_VALIDATE_HOOKS . has ( form ) ) {
301
+ // Patch form methods to add event listener hooks. These are needed to react
302
+ // to form behaviors that do not dispatch events, such as a form asking its
303
+ // controls to report their validity.
304
+ //
305
+ // We should only patch the methods once, since multiple controls and other
306
+ // forces may want to patch this method. We cannot reliably clean it up if
307
+ // there are multiple patched and re-patched methods referring holding
308
+ // references to each other.
309
+ //
310
+ // Instead, we never clean up the patch but add and clean up event listeners
311
+ // added to the hooks after the patch.
253
312
const hooks = new EventTarget ( ) ;
254
- const superReportValidity = form . reportValidity ;
255
- form . reportValidity = function ( this : HTMLFormElement ) {
256
- hooks . dispatchEvent ( new Event ( 'before' ) ) ;
257
- const valid = superReportValidity . call ( this ) ;
258
- hooks . dispatchEvent ( new Event ( 'after' ) ) ;
259
- return valid ;
260
- } ;
313
+ FORM_VALIDATE_HOOKS . set ( form , hooks ) ;
261
314
262
- FORM_REPORT_VALIDITY_HOOKS . set ( form , hooks ) ;
315
+ // Add hooks to support notifying before and after a form has run constraint
316
+ // validation on its controls.
317
+ // Note: `form.submit()` does not run constraint validation per spec.
318
+ for ( const methodName of [ 'reportValidity' , 'requestSubmit' ] as const ) {
319
+ const superMethod = form [ methodName ] ;
320
+ form [ methodName ] = function ( this : HTMLFormElement ) {
321
+ hooks . dispatchEvent ( new Event ( 'before' ) ) ;
322
+ const result = Reflect . apply ( superMethod , this , arguments ) ;
323
+ hooks . dispatchEvent ( new Event ( 'after' ) ) ;
324
+ return result ;
325
+ } ;
326
+ }
263
327
}
264
328
265
- const hooks = FORM_REPORT_VALIDITY_HOOKS . get ( form ) ! ;
266
- hooks . addEventListener ( 'before' , beforeReportValidity , { signal : cleanup } ) ;
267
- hooks . addEventListener ( 'after' , afterReportValidity , { signal : cleanup } ) ;
329
+ return FORM_VALIDATE_HOOKS . get ( form ) ! ;
268
330
}
0 commit comments