Skip to content

Commit 3151fd8

Browse files
asyncLizcopybara-github
authored andcommitted
fix(textfield): error styles not removing when an unrelated control is invalid
The bug: given a form with two required text fields, 1. Try to submit the form, both fields show error. 2. Add a value to the first field. 3. Try to submit the form, the first field does not remove its error. This is fixed by listening to form submits and clearing the error state if the control is valid. I refactored `injectFormReportValidityHooks()` into `addFormReportValidListener()` to keep the `OnReportValidity` class cleaner and better identify the problem we're trying to solve. PiperOrigin-RevId: 597664564
1 parent f2ebcda commit 3151fd8

File tree

3 files changed

+192
-75
lines changed

3 files changed

+192
-75
lines changed

labs/behaviors/on-report-validity.ts

Lines changed: 137 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -168,101 +168,163 @@ export function mixinOnReportValidity<
168168
super.formAssociatedCallback(form);
169169
}
170170

171-
// Clean up previous submit listener
171+
// Clean up previous form listeners.
172172
this[privateCleanupFormListeners].abort();
173173
if (!form) {
174174
return;
175175
}
176176

177177
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,
183187
() => {
184188
this[onReportValidity](null);
185189
},
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+
},
186233
{
187-
signal: this[privateCleanupFormListeners].signal,
234+
signal: cleanupInvalidListener.signal,
188235
},
189236
);
237+
},
238+
{signal: cleanup},
239+
);
190240

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+
}
219249

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+
);
227254

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.
229285
}
230286

231-
const FORM_REPORT_VALIDITY_HOOKS = new WeakMap<HTMLFormElement, EventTarget>();
287+
const FORM_VALIDATE_HOOKS = new WeakMap<HTMLFormElement, EventTarget>();
232288

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.
253312
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);
261314

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+
}
263327
}
264328

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)!;
268330
}

labs/behaviors/on-report-validity_test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,17 @@ describe('mixinOnReportValidity()', () => {
158158
form.remove();
159159
expect(control[onReportValidity]).toHaveBeenCalledOnceWith(null);
160160
});
161+
162+
it('should be called with null when form submits declaratively and it is valid, but another sibling is invalid', () => {
163+
// This is a known limitation. If a form is using an MWC control and
164+
// declaratively submits it with a native `<button>` or `<input>`, then
165+
// error styles will not clear if the form fails to submit.
166+
// The workaround is to call `form.reportValidity()` when clicking the
167+
// native `<button type="submit">` or pressing enter in an `<input>`.
168+
//
169+
// Leaving this test here for documentation and a possible future fix.
170+
expect().nothing();
171+
});
161172
});
162173

163174
describe('for valid to invalid controls', () => {
@@ -348,6 +359,44 @@ describe('mixinOnReportValidity()', () => {
348359
expect(control[onReportValidity]).toHaveBeenCalledOnceWith(null);
349360
});
350361

362+
it('should be called with null when form.requestSubmit() is called after fixing invalid, but another sibling is invalid', () => {
363+
const control = new TestOnReportValidity();
364+
const onReportValiditySpy = jasmine.createSpy('onReportValidity');
365+
control[onReportValidity] = onReportValiditySpy;
366+
const form = document.createElement('form');
367+
form.appendChild(control);
368+
369+
const invalidSibling = document.createElement('input');
370+
invalidSibling.required = true;
371+
form.appendChild(invalidSibling);
372+
373+
document.body.appendChild(form);
374+
form.addEventListener(
375+
'submit',
376+
(event) => {
377+
// Prevent the test page from actually reloading.
378+
event.preventDefault();
379+
},
380+
{capture: true},
381+
);
382+
383+
control.required = true;
384+
form.reportValidity();
385+
onReportValiditySpy.calls.reset();
386+
387+
// Fix invalid
388+
control.checked = true;
389+
390+
// Submit imperatively
391+
form.requestSubmit();
392+
form.remove();
393+
394+
expect(invalidSibling.validity.valid)
395+
.withContext('sibling is invalid')
396+
.toBeFalse();
397+
expect(control[onReportValidity]).toHaveBeenCalledWith(null);
398+
});
399+
351400
it('should be called with null when form submits declaratively after fixing invalid', () => {
352401
const control = new TestOnReportValidity();
353402
const onReportValiditySpy = jasmine.createSpy('onReportValidity');
@@ -379,6 +428,11 @@ describe('mixinOnReportValidity()', () => {
379428

380429
expect(control[onReportValidity]).toHaveBeenCalledOnceWith(null);
381430
});
431+
432+
it('should be called with null when form submits declaratively after fixing invalid, but another sibling is invalid', () => {
433+
// See above "This is a known limitation" for explanation.
434+
expect().nothing();
435+
});
382436
});
383437

384438
it('should clean up when form is unassociated and not call when non-parent form.reportValidity() is called', () => {

textfield/demo/stories.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ const forms: MaterialStoryInit<StoryKnobs> = {
216216
?disabled=${knobs.disabled}
217217
label="Last name"
218218
name="last-name"
219+
required
219220
autocomplete="family-name"></md-filled-text-field>
220221
</div>
221222
<div class="row buttons">

0 commit comments

Comments
 (0)