@@ -36,6 +36,10 @@ export class Dialog extends LitElement {
36
36
requestUpdateOnAriaChange ( Dialog ) ;
37
37
}
38
38
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
+
39
43
/**
40
44
* Opens the dialog when set to `true` and closes it when set to `false`.
41
45
*/
@@ -78,6 +82,21 @@ export class Dialog extends LitElement {
78
82
*/
79
83
@property ( ) type ?: 'alert' ;
80
84
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
+
81
100
/**
82
101
* Gets the opening animation for a dialog. Set to a new function to customize
83
102
* the animation.
@@ -106,6 +125,8 @@ export class Dialog extends LitElement {
106
125
@query ( '.scroller' ) private readonly scroller ! : HTMLElement | null ;
107
126
@query ( '.top.anchor' ) private readonly topAnchor ! : HTMLElement | null ;
108
127
@query ( '.bottom.anchor' ) private readonly bottomAnchor ! : HTMLElement | null ;
128
+ @query ( '.focus-trap' )
129
+ private readonly firstFocusTrap ! : HTMLElement | null ;
109
130
private nextClickIsFromContent = false ;
110
131
private intersectionObserver ?: IntersectionObserver ;
111
132
// Dialogs should not be SSR'd while open, so we can just use runtime checks.
@@ -128,31 +149,17 @@ export class Dialog extends LitElement {
128
149
// in Chromium is fixed to fire 'cancel' with one escape press and close with
129
150
// multiple.
130
151
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
+ ) ;
131
158
132
159
constructor ( ) {
133
160
super ( ) ;
134
161
if ( ! isServer ) {
135
162
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
- } ) ;
156
163
}
157
164
}
158
165
@@ -184,6 +191,7 @@ export class Dialog extends LitElement {
184
191
) ;
185
192
if ( preventOpen ) {
186
193
this . open = false ;
194
+ this . isOpening = false ;
187
195
return ;
188
196
}
189
197
@@ -268,6 +276,17 @@ export class Dialog extends LitElement {
268
276
'show-bottom-divider' : scrollable && ! this . isAtScrollBottom ,
269
277
} ;
270
278
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
+
271
290
const { ariaLabel} = this as ARIAMixinStrict ;
272
291
return html `
273
292
< div class ="scrim "> </ div >
@@ -281,6 +300,7 @@ export class Dialog extends LitElement {
281
300
@close=${ this . handleClose }
282
301
@keydown=${ this . handleKeydown }
283
302
.returnValue=${ this . returnValue || nothing } >
303
+ ${ showFocusTrap ? focusTrap : nothing }
284
304
< div class ="container " @click =${ this . handleContentClick } >
285
305
< div class ="headline ">
286
306
< div class ="icon " aria-hidden ="true ">
@@ -305,6 +325,7 @@ export class Dialog extends LitElement {
305
325
< slot name ="actions " @slotchange =${ this . handleActionsChange } > </ slot >
306
326
</ div >
307
327
</ div >
328
+ ${ showFocusTrap ? focusTrap : nothing }
308
329
</ dialog >
309
330
` ;
310
331
}
@@ -487,4 +508,114 @@ export class Dialog extends LitElement {
487
508
this . isConnectedPromiseResolve = resolve ;
488
509
} ) ;
489
510
}
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 ;
490
621
}
0 commit comments