11import { overlays } from './singleton.js' ;
2- import { containFocus } from './utils/contain-focus.js' ;
32import { deepContains } from './utils/deep-contains.js' ;
43import { overlayShadowDomStyle } from './overlayShadowDomStyle.js' ;
54import { _adoptStyleUtils } from './utils/adopt-styles.js' ;
5+ import { getFocusableElements } from './utils/get-focusable-elements.js' ;
66
77/**
88 * @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove' } OverlayPhase
@@ -113,6 +113,11 @@ const childDialogsClosedInEventLoopWeakmap = new WeakMap();
113113 *
114114 */
115115export class OverlayController extends EventTarget {
116+ /**
117+ * 'True' when Shift key is pressed, 'false' otherwise
118+ */
119+ #isShiftPressed = false ;
120+
116121 /**
117122 * @constructor
118123 * @param {OverlayConfig } config initial config. Will be remembered as shared config
@@ -201,8 +206,6 @@ export class OverlayController extends EventTarget {
201206 this . __escKeyHandler = this . __escKeyHandler . bind ( this ) ;
202207 this . updateConfig ( config ) ;
203208 /** @private */
204- this . __hasActiveTrapsKeyboardFocus = false ;
205- /** @private */
206209 this . __hasActiveBackdrop = true ;
207210 /** @private */
208211 this . __cancelHandler = this . __cancelHandler . bind ( this ) ;
@@ -591,9 +594,7 @@ export class OverlayController extends EventTarget {
591594 * @private
592595 */
593596 __initContentDomStructure ( ) {
594- const wrappingDialogElement = document . createElement (
595- this . config ?. _noDialogEl ? 'div' : 'dialog' ,
596- ) ;
597+ const wrappingDialogElement = document . createElement ( 'dialog' ) ;
597598 // We use a dialog for its visual capabilities: it renders to the top layer.
598599 // A11y will depend on the type of overlay and is arranged on contentNode level.
599600 // Also see: https://www.scottohara.me/blog/2019/03/05/open-dialog.html
@@ -625,7 +626,6 @@ export class OverlayController extends EventTarget {
625626 contentWrapperNodeL2 : this . contentWrapperNode ,
626627 contentNodeL3 : this . contentNode ,
627628 } ) ;
628- // @ts -expect-error
629629 wrappingDialogElement . open = true ;
630630
631631 if ( this . isTooltip ) {
@@ -1145,65 +1145,97 @@ export class OverlayController extends EventTarget {
11451145 }
11461146 }
11471147
1148- get hasActiveTrapsKeyboardFocus ( ) {
1149- return this . __hasActiveTrapsKeyboardFocus ;
1150- }
1151-
11521148 /**
1153- * @param {{ phase: OverlayPhase } } config
1154- * @protected
1149+ * @param {KeyboardEvent } event
11551150 */
1156- _handleTrapsKeyboardFocus ( { phase } ) {
1157- if ( phase === 'show' ) {
1158- // @ts -ignore
1159- if ( 'showModal' in this . __wrappingDialogNode ) {
1160- // @ts -ignore
1161- this . __wrappingDialogNode . close ( ) ;
1162- // @ts -ignore
1163- this . __wrappingDialogNode . showModal ( ) ;
1164- }
1165- // else {
1166- this . enableTrapsKeyboardFocus ( ) ;
1167- // }
1168- } else if ( phase === 'hide' || phase === 'teardown' ) {
1169- this . disableTrapsKeyboardFocus ( ) ;
1151+ #isShiftPressedOnKeyDownHandler = event => {
1152+ if ( event . key === 'Shift' ) {
1153+ this . #isShiftPressed = true ;
11701154 }
1171- }
1155+ } ;
11721156
1173- enableTrapsKeyboardFocus ( ) {
1174- if ( this . __hasActiveTrapsKeyboardFocus ) {
1175- return ;
1176- }
1177- if ( this . manager ) {
1178- this . manager . disableTrapsKeyboardFocusForAll ( ) ;
1157+ /**
1158+ * @param { KeyboardEvent } event
1159+ */
1160+ #isShiftPressedOnKeyUpHandler = event => {
1161+ if ( event . key === 'Shift' ) {
1162+ this . #isShiftPressed = false ;
11791163 }
1164+ } ;
11801165
1181- const isContentShadowHost = Boolean ( this . contentNode . shadowRoot ) ;
1182- if ( isContentShadowHost ) {
1183- // eslint-disable-next-line no-console
1184- console . warn (
1185- '[overlays]: For best accessibility (compatibility with Safari + VoiceOver), provide a contentNode that is not a host for a shadow root' ,
1186- ) ;
1187- }
1166+ #handleShiftKeyPress = ( ) => {
1167+ window . addEventListener ( 'keydown' , this . #isShiftPressedOnKeyDownHandler) ;
1168+ window . addEventListener ( 'keyup' , this . #isShiftPressedOnKeyUpHandler) ;
1169+ } ;
11881170
1189- this . _containFocusHandler = containFocus ( this . contentNode ) ;
1190- this . __hasActiveTrapsKeyboardFocus = true ;
1191- if ( this . manager ) {
1192- this . manager . informTrapsKeyboardFocusGotEnabled ( this . placementMode ) ;
1193- }
1194- }
1171+ #stopHandlingShiftKeyPress = ( ) => {
1172+ window . removeEventListener ( 'keydown' , this . #isShiftPressedOnKeyDownHandler) ;
1173+ window . removeEventListener ( 'keyup' , this . #isShiftPressedOnKeyUpHandler) ;
1174+ } ;
11951175
1196- disableTrapsKeyboardFocus ( { findNewTrap = true } = { } ) {
1197- if ( ! this . __hasActiveTrapsKeyboardFocus ) {
1198- return ;
1176+ #getInitialElementToFocus = ( ) => {
1177+ const focusableElements = getFocusableElements ( this . contentNode ) ;
1178+ // Initial focus goes to first element with autofocus, or `contentNode`
1179+ return focusableElements . find ( e => e . hasAttribute ( 'autofocus' ) ) || this . contentNode ;
1180+ } ;
1181+
1182+ /**
1183+ * When a `dialog` element gets focused, we focus programmatically something
1184+ * else inside dialog for better a11y. A dialog element gets focused natively in these cases:
1185+ * 1) When called by `showModal()` first time
1186+ * 2) When focus is rotating. That is when a user navigates using Tab key through
1187+ * all the dialog's focusable elements, then the focus goes to the browser's URL,
1188+ * all its tabs and then the focus goes back to the dialog element
1189+ * 3) Same as in the point #2, but when a user navigates backward by hitting `Shift + Tab`.
1190+ * In this case we do not intercept and let the focus pass through. Otherwise the focus
1191+ * will never leaves the dialog
1192+ *
1193+ * Note, Chrome does not focus `Dialog` element when Tabbing. When dialog is opened first time,
1194+ * it focuses the contentNode if that has `tabindex` set. But the second time when we
1195+ * move to the dialog from URL bar, nor the dialog element, nor the `contentNode` are focused.
1196+ * Instead the first focusable element is focused right away
1197+ */
1198+ #handleFocusInsideDialog = ( ) => {
1199+ this . __wrappingDialogNode ?. addEventListener ( 'focus' , ( ) => {
1200+ if ( ! this . #isShiftPressed) {
1201+ this . #getInitialElementToFocus( ) . focus ( ) ;
1202+ }
1203+ } ) ;
1204+ } ;
1205+
1206+ /**
1207+ * @param {{ phase: OverlayPhase } } config
1208+ * @protected
1209+ */
1210+ _handleTrapsKeyboardFocus ( { phase } ) {
1211+ if ( phase === 'init' ) {
1212+ this . contentNode . style . outline = 'none' ;
1213+ this . contentNode . tabIndex = - 1 ;
1214+
1215+ const isContentShadowHost = Boolean ( this . contentNode . shadowRoot ) ;
1216+ if ( isContentShadowHost ) {
1217+ // eslint-disable-next-line no-console
1218+ console . warn (
1219+ '[overlays]: For best accessibility (compatibility with Safari + VoiceOver), provide a contentNode that is not a host for a shadow root' ,
1220+ ) ;
1221+ }
11991222 }
1200- if ( this . _containFocusHandler ) {
1201- this . _containFocusHandler . disconnect ( ) ;
1202- this . _containFocusHandler = undefined ;
1223+ if ( phase === 'show' ) {
1224+ this . #handleShiftKeyPress( ) ;
1225+ this . #handleFocusInsideDialog( ) ;
1226+ this . __wrappingDialogNode ?. close ( ) ;
1227+ this . __wrappingDialogNode ?. showModal ( ) ;
1228+ /**
1229+ * At this moment `#handleFocusInsideDialog` should handle the focus.
1230+ * But for some reason Firefox on the testing setup does not
1231+ * focus the native `dialog` on showModal() and focuses the first
1232+ * focusable element inside the dialog instead. Hence here we focus
1233+ * contentNode explicitly
1234+ */
1235+ this . #getInitialElementToFocus( ) . focus ( ) ;
12031236 }
1204- this . __hasActiveTrapsKeyboardFocus = false ;
1205- if ( this . manager ) {
1206- this . manager . informTrapsKeyboardFocusGotDisabled ( { disabledCtrl : this , findNewTrap } ) ;
1237+ if ( phase === 'hide' ) {
1238+ this . #stopHandlingShiftKeyPress( ) ;
12071239 }
12081240 }
12091241
@@ -1231,12 +1263,7 @@ export class OverlayController extends EventTarget {
12311263 return ;
12321264 }
12331265
1234- const hasPressedInside =
1235- event . composedPath ( ) . includes ( this . contentNode ) ||
1236- ( this . invokerNode && event . composedPath ( ) . includes ( this . invokerNode ) ) ||
1237- deepContains ( this . contentNode , /** @type {HTMLElement|ShadowRoot } */ ( event . target ) ) ;
1238-
1239- if ( hasPressedInside ) {
1266+ if ( this . #hasPressedInside( event ) ) {
12401267 this . __escKeyHandlerCalled = true ;
12411268 this . hide ( ) ;
12421269 // We could do event.stopPropagation() here, but we don't want to hide info for
@@ -1246,17 +1273,23 @@ export class OverlayController extends EventTarget {
12461273 }
12471274 }
12481275
1276+ /**
1277+ * @param {KeyboardEvent } event
1278+ * @returns {boolean }
1279+ */
1280+ #hasPressedInside = event =>
1281+ event . composedPath ( ) . includes ( /** @type {EventTarget } */ ( this . __wrappingDialogNode ) ) ||
1282+ ( this . invokerNode && event . composedPath ( ) . includes ( this . invokerNode ) ) ||
1283+ deepContains ( this . contentNode , /** @type {HTMLElement|ShadowRoot } */ ( event . target ) ) ;
1284+
12491285 /**
12501286 * @param {KeyboardEvent } event
12511287 * @returns {void }
12521288 */
12531289 #outsideEscKeyHandler = event => {
12541290 if ( event . key !== 'Escape' ) return ;
12551291
1256- const hasPressedInside =
1257- event . composedPath ( ) . includes ( this . contentNode ) ||
1258- deepContains ( this . contentNode , /** @type {HTMLElement|ShadowRoot } */ ( event . target ) ) ;
1259- if ( hasPressedInside ) return ;
1292+ if ( this . #hasPressedInside( event ) ) return ;
12601293 this . hide ( ) ;
12611294 } ;
12621295
0 commit comments