Skip to content

Commit b09b1af

Browse files
authored
feature: native dialog trap focus (#2700)
1 parent d39c6b7 commit b09b1af

File tree

9 files changed

+175
-570
lines changed

9 files changed

+175
-570
lines changed

.changeset/nice-dancers-grab.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@lion/ui': minor
3+
---
4+
5+
[overlays] update trapskeyboardfocus feature to use native dialog
6+
7+
[trapskeyboardfocus](https://lion.js.org/fundamentals/systems/overlays/configuration/#trapskeyboardfocus) feature is updated to use native Dialog for trapping focus. It bring some breaking changes:
8+
9+
- Endpoints removed: `containFocus`, `rotateFocus` from `@lion/ui/overlays.js`
10+
- Undocumented protected configuration property for Overlays `_noDialogEl` is removed. It allowed to use `div` element instead of `dialog` for rendering dialogs
11+
- `OverlayController#enableTrapsKeyboardFocus`, `OverlayController#disableTrapsKeyboardFocus` have been removed
12+
- `OverlaysManager#disableTrapsKeyboardFocusForAll`, `OverlaysManager#informTrapsKeyboardFocusGotEnabled`, `OverlaysManager#informTrapsKeyboardFocusGotDisabled` have been removed
13+
14+
The change in the behaviour from UX point of view:
15+
The main difference is that the navite modal dialog does not trap the focus inside the dialog entirely. It allows to switch from the dialog to the browser's panels. It's done by design to improve a11y expirience and let users switch browser's tab as example if they want so.

docs/fundamentals/systems/overlays/configuration.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ Boolean property. When true, the focus will rotate through the **focusable eleme
139139

140140
For Modal Dialogs this is an important feature, since these are considered "their own page",
141141
so especially from an accessibility point of view, trapping the focus inside the dialog is crucial.
142+
Note, the implementation relies on the platform's `HTMLDialogElement` `showModal()` feature, which makes focus trapped
143+
inside the dialog within the page but also allows the browser to switch the focus from the page to the
144+
browser's panels like address bar and others.
142145

143146
You use the feature on any type of overlay.
144147

packages/ui/.gitignore

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1-
./docs
1+
# Files generated on prepublishing
2+
/docs
3+
/define*
4+
/*.js
5+
/*translations
26
vscode.css-custom-data.json
3-
vscode.html-custom-data.json
7+
vscode.html-custom-data.json

packages/ui/components/overlays/src/OverlayController.js

Lines changed: 99 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { overlays } from './singleton.js';
2-
import { containFocus } from './utils/contain-focus.js';
32
import { deepContains } from './utils/deep-contains.js';
43
import { overlayShadowDomStyle } from './overlayShadowDomStyle.js';
54
import { _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
*/
115115
export 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

packages/ui/components/overlays/src/OverlaysManager.js

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -134,39 +134,6 @@ export class OverlaysManager {
134134
return this.__siblingsInert;
135135
}
136136

137-
disableTrapsKeyboardFocusForAll() {
138-
this.shownList.forEach(ctrl => {
139-
if (ctrl.trapsKeyboardFocus === true && ctrl.disableTrapsKeyboardFocus) {
140-
ctrl.disableTrapsKeyboardFocus({ findNewTrap: false });
141-
}
142-
});
143-
}
144-
145-
/**
146-
* @param {'local' | 'global' | undefined} placementMode
147-
*/
148-
informTrapsKeyboardFocusGotEnabled(placementMode) {
149-
if (this.siblingsInert === false && placementMode === 'global') {
150-
this.__siblingsInert = true;
151-
}
152-
}
153-
154-
/**
155-
* @param {{ disabledCtrl?:OverlayController, findNewTrap?:boolean }} options
156-
*/
157-
informTrapsKeyboardFocusGotDisabled({ disabledCtrl, findNewTrap = true } = {}) {
158-
const next = this.shownList.find(
159-
ctrl => ctrl !== disabledCtrl && ctrl.trapsKeyboardFocus === true,
160-
);
161-
if (next) {
162-
if (findNewTrap) {
163-
next.enableTrapsKeyboardFocus();
164-
}
165-
} else if (this.siblingsInert === true) {
166-
this.__siblingsInert = false;
167-
}
168-
}
169-
170137
/** PreventsScroll */
171138

172139
// eslint-disable-next-line class-methods-use-this

0 commit comments

Comments
 (0)