Skip to content

Commit 0aea436

Browse files
asyncLizcopybara-github
authored andcommitted
fix(dialog): focus is trapped for a11y, use no-focus-trap to disable
PiperOrigin-RevId: 626403597
1 parent b73792a commit 0aea436

File tree

3 files changed

+172
-32
lines changed

3 files changed

+172
-32
lines changed

dialog/demo/demo.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
2424
defaultValue: false,
2525
ui: boolInput(),
2626
}),
27+
new Knob('noFocusTrap', {
28+
defaultValue: false,
29+
ui: boolInput(),
30+
}),
2731
new Knob('icon', {defaultValue: '', ui: textInput()}),
2832
new Knob('headline', {defaultValue: 'Dialog', ui: textInput()}),
2933
new Knob('supportingText', {

dialog/demo/stories.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {css, html, nothing} from 'lit';
2020
/** Knob types for dialog stories. */
2121
export interface StoryKnobs {
2222
quick: boolean;
23+
noFocusTrap: boolean;
2324
icon: string;
2425
headline: string;
2526
supportingText: string;
@@ -31,15 +32,16 @@ function showDialog(event: Event) {
3132

3233
const standard: MaterialStoryInit<StoryKnobs> = {
3334
name: 'Dialog',
34-
render({icon, headline, supportingText, quick}) {
35+
render({icon, headline, supportingText, quick, noFocusTrap}) {
3536
return html`
3637
<md-filled-button @click=${showDialog} aria-label="Open a dialog">
3738
Open
3839
</md-filled-button>
3940
4041
<md-dialog
4142
aria-label=${headline ? nothing : 'A simple dialog'}
42-
?quick=${quick}>
43+
?quick=${quick}
44+
?no-focus-trap=${noFocusTrap}>
4345
${icon ? html`<md-icon slot="icon">${icon}</md-icon>` : nothing}
4446
<div slot="headline">${headline}</div>
4547
<form id="form" slot="content" method="dialog">
@@ -56,13 +58,13 @@ const standard: MaterialStoryInit<StoryKnobs> = {
5658

5759
const alert: MaterialStoryInit<StoryKnobs> = {
5860
name: 'Alert',
59-
render({quick}) {
61+
render({quick, noFocusTrap}) {
6062
return html`
6163
<md-filled-button @click=${showDialog} aria-label="Open an alert dialog">
6264
Alert
6365
</md-filled-button>
6466
65-
<md-dialog type="alert" ?quick=${quick}>
67+
<md-dialog type="alert" ?quick=${quick} ?no-focus-trap=${noFocusTrap}>
6668
<div slot="headline">Alert dialog</div>
6769
<form id="form" slot="content" method="dialog">
6870
This is a standard alert dialog. Alert dialogs interrupt users with
@@ -78,15 +80,18 @@ const alert: MaterialStoryInit<StoryKnobs> = {
7880

7981
const confirm: MaterialStoryInit<StoryKnobs> = {
8082
name: 'Confirm',
81-
render({quick}) {
83+
render({quick, noFocusTrap}) {
8284
return html`
8385
<md-filled-button
8486
@click=${showDialog}
8587
aria-label="Open a confirmation dialog">
8688
Confirm
8789
</md-filled-button>
8890
89-
<md-dialog style="max-width: 320px;" ?quick=${quick}>
91+
<md-dialog
92+
style="max-width: 320px;"
93+
?quick=${quick}
94+
?no-focus-trap=${noFocusTrap}>
9095
<div slot="headline">Permanently delete?</div>
9196
<md-icon slot="icon">delete_outline</md-icon>
9297
<form id="form" slot="content" method="dialog">
@@ -112,13 +117,13 @@ const choose: MaterialStoryInit<StoryKnobs> = {
112117
align-items: center;
113118
}
114119
`,
115-
render({quick}) {
120+
render({quick, noFocusTrap}) {
116121
return html`
117122
<md-filled-button @click=${showDialog} aria-label="Open a choice dialog">
118123
Choice
119124
</md-filled-button>
120125
121-
<md-dialog ?quick=${quick}>
126+
<md-dialog ?quick=${quick} ?no-focus-trap=${noFocusTrap}>
122127
<div slot="headline">Choose your favorite pet</div>
123128
<form id="form" slot="content" method="dialog">
124129
<label>
@@ -187,13 +192,13 @@ const contacts: MaterialStoryInit<StoryKnobs> = {
187192
flex: 1;
188193
}
189194
`,
190-
render({quick}) {
195+
render({quick, noFocusTrap}) {
191196
return html`
192197
<md-filled-button @click=${showDialog} aria-label="Open a form dialog">
193198
Form
194199
</md-filled-button>
195200
196-
<md-dialog class="contacts" ?quick=${quick}>
201+
<md-dialog class="contacts" ?quick=${quick} ?no-focus-trap=${noFocusTrap}>
197202
<span slot="headline">
198203
<md-icon-button form="form" value="close" aria-label="Close dialog">
199204
<md-icon>close</md-icon>
@@ -229,13 +234,13 @@ const contacts: MaterialStoryInit<StoryKnobs> = {
229234

230235
const floatingSheet: MaterialStoryInit<StoryKnobs> = {
231236
name: 'Floating sheet',
232-
render({quick}) {
237+
render({quick, noFocusTrap}) {
233238
return html`
234239
<md-filled-button @click=${showDialog} aria-label="Open a floating sheet">
235240
Floating sheet
236241
</md-filled-button>
237242
238-
<md-dialog ?quick=${quick}>
243+
<md-dialog ?quick=${quick} ?no-focus-trap=${noFocusTrap}>
239244
<span slot="headline">
240245
<span style="flex: 1;">Floating Sheet</span>
241246
<md-icon-button form="form" value="close" aria-label="Close dialog">

dialog/internal/dialog.ts

Lines changed: 151 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export class Dialog extends LitElement {
3636
requestUpdateOnAriaChange(Dialog);
3737
}
3838

39+
// We do not use `delegatesFocus: true` due to a Chromium bug with
40+
// selecting text.
41+
// See https://bugs.chromium.org/p/chromium/issues/detail?id=950357
42+
3943
/**
4044
* Opens the dialog when set to `true` and closes it when set to `false`.
4145
*/
@@ -78,6 +82,21 @@ export class Dialog extends LitElement {
7882
*/
7983
@property() type?: 'alert';
8084

85+
/**
86+
* Disables focus trapping, which by default keeps keyboard Tab navigation
87+
* within the dialog.
88+
*
89+
* When disabled, after focusing the last element of a dialog, pressing Tab
90+
* again will release focus from the window back to the browser (such as the
91+
* URL bar).
92+
*
93+
* Focus trapping is recommended for accessibility, and should not typically
94+
* be disabled. Only turn this off if the use case of a dialog is more
95+
* accessible without focus trapping.
96+
*/
97+
@property({type: Boolean, attribute: 'no-focus-trap'})
98+
noFocusTrap = false;
99+
81100
/**
82101
* Gets the opening animation for a dialog. Set to a new function to customize
83102
* the animation.
@@ -106,6 +125,8 @@ export class Dialog extends LitElement {
106125
@query('.scroller') private readonly scroller!: HTMLElement | null;
107126
@query('.top.anchor') private readonly topAnchor!: HTMLElement | null;
108127
@query('.bottom.anchor') private readonly bottomAnchor!: HTMLElement | null;
128+
@query('.focus-trap')
129+
private readonly firstFocusTrap!: HTMLElement | null;
109130
private nextClickIsFromContent = false;
110131
private intersectionObserver?: IntersectionObserver;
111132
// Dialogs should not be SSR'd while open, so we can just use runtime checks.
@@ -128,31 +149,17 @@ export class Dialog extends LitElement {
128149
// in Chromium is fixed to fire 'cancel' with one escape press and close with
129150
// multiple.
130151
private escapePressedWithoutCancel = false;
152+
// This TreeWalker is used to walk through a dialog's children to find
153+
// focusable elements. TreeWalker is faster than `querySelectorAll('*')`.
154+
private readonly treewalker = document.createTreeWalker(
155+
this,
156+
NodeFilter.SHOW_ELEMENT,
157+
);
131158

132159
constructor() {
133160
super();
134161
if (!isServer) {
135162
this.addEventListener('submit', this.handleSubmit);
136-
137-
// We do not use `delegatesFocus: true` due to a Chromium bug with
138-
// selecting text.
139-
// See https://bugs.chromium.org/p/chromium/issues/detail?id=950357
140-
//
141-
// Material requires using focus trapping within the dialog (see
142-
// b/314840853 for the bug to add it). This would normally mean we don't
143-
// care about delegating focus since the `<dialog>` never receives it.
144-
// However, we still need to handle situations when a user has not
145-
// provided an focusable child in the content. When that happens, the
146-
// `<dialog>` itself is focused.
147-
//
148-
// Listen to focus/blur instead of focusin/focusout since those can bubble
149-
// from content.
150-
this.addEventListener('focus', () => {
151-
this.dialog?.focus();
152-
});
153-
this.addEventListener('blur', () => {
154-
this.dialog?.blur();
155-
});
156163
}
157164
}
158165

@@ -184,6 +191,7 @@ export class Dialog extends LitElement {
184191
);
185192
if (preventOpen) {
186193
this.open = false;
194+
this.isOpening = false;
187195
return;
188196
}
189197

@@ -268,6 +276,17 @@ export class Dialog extends LitElement {
268276
'show-bottom-divider': scrollable && !this.isAtScrollBottom,
269277
};
270278

279+
// The focus trap sentinels are only added after the dialog opens, since
280+
// dialog.showModal() will try to autofocus them, even with tabindex="-1".
281+
const showFocusTrap = this.open && !this.noFocusTrap;
282+
const focusTrap = html`
283+
<div
284+
class="focus-trap"
285+
tabindex="0"
286+
aria-hidden="true"
287+
@focus=${this.handleFocusTrapFocus}></div>
288+
`;
289+
271290
const {ariaLabel} = this as ARIAMixinStrict;
272291
return html`
273292
<div class="scrim"></div>
@@ -281,6 +300,7 @@ export class Dialog extends LitElement {
281300
@close=${this.handleClose}
282301
@keydown=${this.handleKeydown}
283302
.returnValue=${this.returnValue || nothing}>
303+
${showFocusTrap ? focusTrap : nothing}
284304
<div class="container" @click=${this.handleContentClick}>
285305
<div class="headline">
286306
<div class="icon" aria-hidden="true">
@@ -305,6 +325,7 @@ export class Dialog extends LitElement {
305325
<slot name="actions" @slotchange=${this.handleActionsChange}></slot>
306326
</div>
307327
</div>
328+
${showFocusTrap ? focusTrap : nothing}
308329
</dialog>
309330
`;
310331
}
@@ -487,4 +508,114 @@ export class Dialog extends LitElement {
487508
this.isConnectedPromiseResolve = resolve;
488509
});
489510
}
511+
512+
private handleFocusTrapFocus(event: FocusEvent) {
513+
const [firstFocusableChild, lastFocusableChild] =
514+
this.getFirstAndLastFocusableChildren();
515+
if (!firstFocusableChild || !lastFocusableChild) {
516+
// When a dialog does not have focusable children, the dialog itself
517+
// receives focus.
518+
this.dialog?.focus();
519+
return;
520+
}
521+
522+
// To determine which child to focus, we need to know which focus trap
523+
// received focus...
524+
const isFirstFocusTrap = event.target === this.firstFocusTrap;
525+
const isLastFocusTrap = !isFirstFocusTrap;
526+
// ...and where the focus came from (what was previously focused).
527+
const focusCameFromFirstChild = event.relatedTarget === firstFocusableChild;
528+
const focusCameFromLastChild = event.relatedTarget === lastFocusableChild;
529+
// Although this is a focus trap, focus can come from outside the trap.
530+
// This can happen when elements are programmatically `focus()`'d. It also
531+
// happens when focus leaves and returns to the window, such as clicking on
532+
// the browser's URL bar and pressing Tab, or switching focus between
533+
// iframes.
534+
const focusCameFromOutsideDialog =
535+
!focusCameFromFirstChild && !focusCameFromLastChild;
536+
537+
// Focus the dialog's first child when we reach the end of the dialog and
538+
// focus is moving forward. Or, when focus is moving forwards into the
539+
// dialog from outside of the window.
540+
const shouldFocusFirstChild =
541+
(isLastFocusTrap && focusCameFromLastChild) ||
542+
(isFirstFocusTrap && focusCameFromOutsideDialog);
543+
if (shouldFocusFirstChild) {
544+
firstFocusableChild.focus();
545+
return;
546+
}
547+
548+
// Focus the dialog's last child when we reach the beginning of the dialog
549+
// and focus is moving backward. Or, when focus is moving backwards into the
550+
// dialog from outside of the window.
551+
const shouldFocusLastChild =
552+
(isFirstFocusTrap && focusCameFromFirstChild) ||
553+
(isLastFocusTrap && focusCameFromOutsideDialog);
554+
if (shouldFocusLastChild) {
555+
lastFocusableChild.focus();
556+
return;
557+
}
558+
559+
// The booleans above are verbose for readability, but code executation
560+
// won't actually reach here.
561+
}
562+
563+
private getFirstAndLastFocusableChildren() {
564+
let firstFocusableChild: HTMLElement | null = null;
565+
let lastFocusableChild: HTMLElement | null = null;
566+
567+
// Reset the current node back to the root host element.
568+
this.treewalker.currentNode = this.treewalker.root;
569+
while (this.treewalker.nextNode()) {
570+
// Cast as Element since the TreeWalker filter only accepts Elements.
571+
const nextChild = this.treewalker.currentNode as Element;
572+
if (!isFocusable(nextChild)) {
573+
continue;
574+
}
575+
576+
if (!firstFocusableChild) {
577+
firstFocusableChild = nextChild;
578+
}
579+
580+
lastFocusableChild = nextChild;
581+
}
582+
583+
// We set lastFocusableChild immediately after finding a
584+
// firstFocusableChild, which means the pair is either both null or both
585+
// non-null. Cast since TypeScript does not recognize this.
586+
return [firstFocusableChild, lastFocusableChild] as
587+
| [HTMLElement, HTMLElement]
588+
| [null, null];
589+
}
590+
}
591+
592+
function isFocusable(element: Element): element is HTMLElement {
593+
// Check if the element is a known built-in focusable element:
594+
// - <a> and <area> with `href` attributes.
595+
// - Form controls that are not disabled.
596+
// - `contenteditable` elements.
597+
// - Anything with a non-negative `tabindex`.
598+
const knownFocusableElements =
599+
':is(button,input,select,textarea,object,:is(a,area)[href],[tabindex],[contenteditable=true])';
600+
const notDisabled = ':not(:disabled,[disabled])';
601+
const notNegativeTabIndex = ':not([tabindex^="-"])';
602+
if (
603+
element.matches(knownFocusableElements + notDisabled + notNegativeTabIndex)
604+
) {
605+
return true;
606+
}
607+
608+
const isCustomElement = element.localName.includes('-');
609+
if (!isCustomElement) {
610+
return false;
611+
}
612+
613+
// If a custom element does not have a tabindex, it may still be focusable
614+
// if it delegates focus with a shadow root. We also need to check again if
615+
// the custom element is a disabled form control.
616+
if (!element.matches(notDisabled)) {
617+
return false;
618+
}
619+
620+
return element.shadowRoot?.delegatesFocus ?? false;
490621
}

0 commit comments

Comments
 (0)