Skip to content

Commit 7dd7a68

Browse files
asyncLizcopybara-github
authored andcommitted
fix: forms correctly focus the first invalid control instead of last
Previously all text fields would focus themselves when the form reports validity, meaning the last one got focus. In reality, reportValidity is supposed to focus the first invalid control. I added a "call" method wrapper around the `onReportValidity` callback that handles focusing logic. PiperOrigin-RevId: 597904790
1 parent 3151fd8 commit 7dd7a68

File tree

4 files changed

+163
-26
lines changed

4 files changed

+163
-26
lines changed

labs/behaviors/on-report-validity.ts

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import {LitElement, isServer} from 'lit';
88

99
import {ConstraintValidation} from './constraint-validation.js';
10+
import {WithElementInternals, internals} from './element-internals.js';
1011
import {MixinBase, MixinReturn} from './mixin.js';
1112

1213
/**
@@ -47,6 +48,8 @@ export const onReportValidity = Symbol('onReportValidity');
4748
// Private symbol members, used to avoid name clashing.
4849
const privateCleanupFormListeners = Symbol('privateCleanupFormListeners');
4950
const privateDoNotReportInvalid = Symbol('privateDoNotReportInvalid');
51+
const privateIsSelfReportingValidity = Symbol('privateIsSelfReportingValidity');
52+
const privateCallOnReportValidity = Symbol('privateCallOnReportValidity');
5053

5154
/**
5255
* Mixes in a callback for constraint validation when validity should be
@@ -81,7 +84,7 @@ const privateDoNotReportInvalid = Symbol('privateDoNotReportInvalid');
8184
* @return The provided class with `OnReportValidity` mixed in.
8285
*/
8386
export function mixinOnReportValidity<
84-
T extends MixinBase<LitElement & ConstraintValidation>,
87+
T extends MixinBase<LitElement & ConstraintValidation & WithElementInternals>,
8588
>(base: T): MixinReturn<T, OnReportValidity> {
8689
abstract class OnReportValidityElement
8790
extends base
@@ -98,6 +101,13 @@ export function mixinOnReportValidity<
98101
*/
99102
[privateDoNotReportInvalid] = false;
100103

104+
/**
105+
* Used to determine if the control is reporting validity from itself, or
106+
* if a `<form>` is causing the validity report. Forms have different
107+
* control focusing behavior.
108+
*/
109+
[privateIsSelfReportingValidity] = false;
110+
101111
// Mixins must have a constructor with `...args: any[]`
102112
// tslint:disable-next-line:no-any
103113
constructor(...args: any[]) {
@@ -124,9 +134,7 @@ export function mixinOnReportValidity<
124134
// A normal bubbling phase event listener. By adding it here, we
125135
// ensure it's the last event listener that is called during the
126136
// bubbling phase.
127-
if (!invalidEvent.defaultPrevented) {
128-
this[onReportValidity](invalidEvent);
129-
}
137+
this[privateCallOnReportValidity](invalidEvent);
130138
},
131139
{once: true},
132140
);
@@ -149,15 +157,50 @@ export function mixinOnReportValidity<
149157
}
150158

151159
override reportValidity() {
160+
this[privateIsSelfReportingValidity] = true;
152161
const valid = super.reportValidity();
153162
// Constructor's invalid listener will handle reporting invalid events.
154163
if (valid) {
155-
this[onReportValidity](null);
164+
this[privateCallOnReportValidity](null);
156165
}
157166

167+
this[privateIsSelfReportingValidity] = false;
158168
return valid;
159169
}
160170

171+
[privateCallOnReportValidity](invalidEvent: Event | null) {
172+
// Since invalid events do not bubble to parent listeners, and because
173+
// our invalid listeners are added lazily after other listeners, we can
174+
// reliably read `defaultPrevented` synchronously without worrying
175+
// about waiting for another listener that could cancel it.
176+
const wasCanceled = invalidEvent?.defaultPrevented;
177+
if (wasCanceled) {
178+
return;
179+
}
180+
181+
this[onReportValidity](invalidEvent);
182+
183+
// If an implementation calls invalidEvent.preventDefault() to stop the
184+
// platform popup from displaying, focusing is also prevented, so we need
185+
// to manually focus.
186+
const implementationCanceledFocus =
187+
!wasCanceled && invalidEvent?.defaultPrevented;
188+
if (!implementationCanceledFocus) {
189+
return;
190+
}
191+
192+
// The control should be focused when:
193+
// - `control.reportValidity()` is called (self-reporting).
194+
// - a form is reporting validity for its controls and this is the first
195+
// invalid control.
196+
if (
197+
this[privateIsSelfReportingValidity] ||
198+
isFirstInvalidControlInForm(this[internals].form, this)
199+
) {
200+
this.focus();
201+
}
202+
}
203+
161204
[onReportValidity](invalidEvent: Event | null) {
162205
throw new Error('Implement [onReportValidity]');
163206
}
@@ -185,7 +228,7 @@ export function mixinOnReportValidity<
185228
this,
186229
form,
187230
() => {
188-
this[onReportValidity](null);
231+
this[privateCallOnReportValidity](null);
189232
},
190233
this[privateCleanupFormListeners].signal,
191234
);
@@ -328,3 +371,31 @@ function getFormValidateHooks(form: HTMLFormElement) {
328371

329372
return FORM_VALIDATE_HOOKS.get(form)!;
330373
}
374+
375+
/**
376+
* Checks if a control is the first invalid control in a form.
377+
*
378+
* @param form The control's form. When `null`, the control doesn't have a form
379+
* and the method returns true.
380+
* @param control The control to check.
381+
* @return True if there is no form or if the control is the form's first
382+
* invalid control.
383+
*/
384+
function isFirstInvalidControlInForm(
385+
form: HTMLFormElement | null,
386+
control: HTMLElement,
387+
) {
388+
if (!form) {
389+
return true;
390+
}
391+
392+
let firstInvalidControl: Element | undefined;
393+
for (const element of form.elements) {
394+
if (element.matches(':invalid')) {
395+
firstInvalidControl = element;
396+
break;
397+
}
398+
}
399+
400+
return firstInvalidControl === control;
401+
}

labs/behaviors/on-report-validity_test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,5 +453,87 @@ describe('mixinOnReportValidity()', () => {
453453
.toHaveBeenCalledTimes(1);
454454
});
455455
});
456+
457+
describe('focusing after preventing platform popup', () => {
458+
it('should focus the control when calling reportValidity()', () => {
459+
const control = new TestOnReportValidity();
460+
control[onReportValidity] = (invalidEvent: Event | null) => {
461+
invalidEvent?.preventDefault();
462+
};
463+
464+
spyOn(control, 'focus');
465+
466+
control.required = true;
467+
control.reportValidity();
468+
expect(control.focus)
469+
.withContext('is focused')
470+
.toHaveBeenCalledTimes(1);
471+
});
472+
473+
it('should only focus the first invalid control of a form', () => {
474+
const firstControl = new TestOnReportValidity();
475+
firstControl[onReportValidity] = (invalidEvent: Event | null) => {
476+
invalidEvent?.preventDefault();
477+
};
478+
479+
const secondControl = new TestOnReportValidity();
480+
secondControl[onReportValidity] = (invalidEvent: Event | null) => {
481+
invalidEvent?.preventDefault();
482+
};
483+
484+
spyOn(firstControl, 'focus');
485+
spyOn(secondControl, 'focus');
486+
487+
const form = document.createElement('form');
488+
form.appendChild(firstControl);
489+
form.appendChild(secondControl);
490+
document.body.appendChild(form);
491+
492+
firstControl.required = true;
493+
secondControl.required = true;
494+
form.reportValidity();
495+
form.remove();
496+
497+
expect(firstControl.focus)
498+
.withContext('first control (invalid) is focused')
499+
.toHaveBeenCalledTimes(1);
500+
expect(secondControl.focus)
501+
.withContext('second control (invalid) is not focused')
502+
.not.toHaveBeenCalled();
503+
});
504+
505+
it('should focus the control when calling control.reportValidity(), even if not the first invalid control of a form', () => {
506+
const firstControl = new TestOnReportValidity();
507+
firstControl[onReportValidity] = (invalidEvent: Event | null) => {
508+
invalidEvent?.preventDefault();
509+
};
510+
511+
const secondControl = new TestOnReportValidity();
512+
secondControl[onReportValidity] = (invalidEvent: Event | null) => {
513+
invalidEvent?.preventDefault();
514+
};
515+
516+
spyOn(firstControl, 'focus');
517+
spyOn(secondControl, 'focus');
518+
519+
const form = document.createElement('form');
520+
form.appendChild(firstControl);
521+
form.appendChild(secondControl);
522+
document.body.appendChild(form);
523+
524+
firstControl.required = true;
525+
secondControl.required = true;
526+
secondControl.reportValidity();
527+
528+
expect(firstControl.focus)
529+
.withContext('first control (invalid) is not focused')
530+
.not.toHaveBeenCalled();
531+
expect(secondControl.focus)
532+
.withContext(
533+
'second control (invalid, called reportValidity()) is focused',
534+
)
535+
.toHaveBeenCalledTimes(1);
536+
});
537+
});
456538
});
457539
});

select/internal/select.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -308,16 +308,8 @@ export abstract class Select extends selectBaseClass {
308308
}
309309

310310
[onReportValidity](invalidEvent: Event | null) {
311-
if (invalidEvent?.defaultPrevented) {
312-
return;
313-
}
314-
315-
if (invalidEvent) {
316-
// Prevent default pop-up behavior. This also prevents focusing, so we
317-
// manually focus.
318-
invalidEvent.preventDefault();
319-
this.focus();
320-
}
311+
// Prevent default pop-up behavior.
312+
invalidEvent?.preventDefault();
321313

322314
const prevMessage = this.getErrorText();
323315
this.nativeError = !!invalidEvent;

textfield/internal/text-field.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -772,16 +772,8 @@ export abstract class TextField extends textFieldBaseClass {
772772
}
773773

774774
[onReportValidity](invalidEvent: Event | null) {
775-
if (invalidEvent?.defaultPrevented) {
776-
return;
777-
}
778-
779-
if (invalidEvent) {
780-
// Prevent default pop-up behavior. This also prevents focusing, so we
781-
// manually focus.
782-
invalidEvent.preventDefault();
783-
this.focus();
784-
}
775+
// Prevent default pop-up behavior.
776+
invalidEvent?.preventDefault();
785777

786778
const prevMessage = this.getErrorText();
787779
this.nativeError = !!invalidEvent;

0 commit comments

Comments
 (0)