@lion/ui 0.15.5 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/overlays/src/OverlayController.js +100 -67
- package/components/overlays/src/OverlaysManager.js +0 -33
- package/components/overlays/test/OverlayController.test.js +88 -30
- package/components/overlays/test-suites/OverlayMixin.suite.js +58 -0
- package/custom-elements.json +105 -200
- package/dist-types/components/overlays/src/OverlayController.d.ts +1 -11
- package/dist-types/components/overlays/src/OverlayController.d.ts.map +1 -1
- package/dist-types/components/overlays/src/OverlaysManager.d.ts +0 -12
- package/dist-types/components/overlays/src/OverlaysManager.d.ts.map +1 -1
- package/dist-types/components/overlays/test/OverlayController.test.d.ts.map +1 -1
- package/dist-types/components/overlays/test-suites/OverlayMixin.suite.d.ts.map +1 -1
- package/dist-types/exports/overlays.d.ts +0 -1
- package/dist-types/tsconfig.tsbuildinfo +1 -1
- package/docs/components/checkbox-group/overview.md +2 -2
- package/docs/components/collapsible/overview.md +1 -1
- package/docs/components/combobox/overview.md +1 -1
- package/docs/components/combobox/use-cases.md +1 -1
- package/docs/components/dialog/overview.md +1 -1
- package/docs/components/fieldset/overview.md +5 -5
- package/docs/components/form/overview.md +3 -3
- package/docs/components/form/use-cases.md +2 -2
- package/docs/components/icon/use-cases.md +1 -1
- package/docs/components/input/overview.md +1 -1
- package/docs/components/input-amount/overview.md +3 -3
- package/docs/components/input-date/overview.md +3 -3
- package/docs/components/input-datepicker/overview.md +5 -5
- package/docs/components/input-email/overview.md +2 -2
- package/docs/components/input-file/overview.md +1 -1
- package/docs/components/input-iban/overview.md +2 -2
- package/docs/components/input-range/overview.md +2 -2
- package/docs/components/input-stepper/overview.md +1 -1
- package/docs/components/input-tel/overview.md +1 -1
- package/docs/components/input-tel/use-cases.md +6 -6
- package/docs/components/input-tel-dropdown/overview.md +1 -1
- package/docs/components/listbox/use-cases.md +1 -1
- package/docs/components/radio-group/overview.md +3 -3
- package/docs/components/select/use-cases.md +1 -1
- package/docs/components/select-rich/use-cases.md +2 -2
- package/docs/components/tabs/examples.md +1 -1
- package/docs/fundamentals/index.md +4 -4
- package/docs/fundamentals/node-tools/publish-docs/overview.md +3 -3
- package/docs/fundamentals/node-tools/rocket-preset-extend-lion-docs/overview.md +2 -2
- package/docs/fundamentals/rationales/accessibility.md +1 -1
- package/docs/fundamentals/systems/core/SlotMixin.md +1 -1
- package/docs/fundamentals/systems/core/overview.md +1 -1
- package/docs/fundamentals/systems/form/formatting-and-parsing.md +4 -4
- package/docs/fundamentals/systems/form/model-value.md +1 -1
- package/docs/fundamentals/systems/form/overview.md +28 -28
- package/docs/fundamentals/systems/form/validate.md +2 -2
- package/docs/fundamentals/systems/localize/overview.md +4 -4
- package/docs/fundamentals/systems/overlays/api-table.md +45 -51
- package/docs/fundamentals/systems/overlays/configuration.md +5 -2
- package/docs/fundamentals/systems/overlays/overview.md +5 -5
- package/docs/fundamentals/systems/overlays/use-cases.md +2 -2
- package/docs/fundamentals/tools/helpers/overview.md +1 -1
- package/docs/fundamentals/tools/singleton-manager/overview.md +4 -4
- package/docs/guides/how-to/create-a-custom-field.md +5 -5
- package/docs/guides/how-to/create-a-form.md +4 -4
- package/docs/guides/how-to/extend-a-native-input.md +2 -2
- package/docs/guides/index.md +8 -8
- package/docs/guides/principles/index.md +5 -5
- package/docs/guides/principles/styling.md +1 -1
- package/exports/overlays.js +0 -1
- package/overlays.js +0 -1
- package/package.json +1 -1
- package/components/overlays/src/utils/contain-focus.js +0 -202
- package/components/overlays/test/utils-tests/contain-focus.test.js +0 -236
- package/dist-types/components/overlays/src/utils/contain-focus.d.ts +0 -20
- package/dist-types/components/overlays/src/utils/contain-focus.d.ts.map +0 -1
- package/dist-types/components/overlays/test/utils-tests/contain-focus.test.d.ts +0 -2
- package/dist-types/components/overlays/test/utils-tests/contain-focus.test.d.ts.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { overlays } from './singleton.js';
|
|
2
|
-
import { containFocus } from './utils/contain-focus.js';
|
|
3
2
|
import { deepContains } from './utils/deep-contains.js';
|
|
4
3
|
import { overlayShadowDomStyle } from './overlayShadowDomStyle.js';
|
|
5
4
|
import { _adoptStyleUtils } from './utils/adopt-styles.js';
|
|
5
|
+
import { getFocusableElements } from './utils/get-focusable-elements.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove'} OverlayPhase
|
|
@@ -113,6 +113,11 @@ const childDialogsClosedInEventLoopWeakmap = new WeakMap();
|
|
|
113
113
|
*
|
|
114
114
|
*/
|
|
115
115
|
export class OverlayController extends EventTarget {
|
|
116
|
+
/**
|
|
117
|
+
* 'True' when Shift key is pressed, 'false' otherwise
|
|
118
|
+
*/
|
|
119
|
+
#isShiftPressed = false;
|
|
120
|
+
|
|
116
121
|
/**
|
|
117
122
|
* @constructor
|
|
118
123
|
* @param {OverlayConfig} config initial config. Will be remembered as shared config
|
|
@@ -201,8 +206,6 @@ export class OverlayController extends EventTarget {
|
|
|
201
206
|
this.__escKeyHandler = this.__escKeyHandler.bind(this);
|
|
202
207
|
this.updateConfig(config);
|
|
203
208
|
/** @private */
|
|
204
|
-
this.__hasActiveTrapsKeyboardFocus = false;
|
|
205
|
-
/** @private */
|
|
206
209
|
this.__hasActiveBackdrop = true;
|
|
207
210
|
/** @private */
|
|
208
211
|
this.__cancelHandler = this.__cancelHandler.bind(this);
|
|
@@ -591,9 +594,7 @@ export class OverlayController extends EventTarget {
|
|
|
591
594
|
* @private
|
|
592
595
|
*/
|
|
593
596
|
__initContentDomStructure() {
|
|
594
|
-
const wrappingDialogElement = document.createElement(
|
|
595
|
-
this.config?._noDialogEl ? 'div' : 'dialog',
|
|
596
|
-
);
|
|
597
|
+
const wrappingDialogElement = document.createElement('dialog');
|
|
597
598
|
// We use a dialog for its visual capabilities: it renders to the top layer.
|
|
598
599
|
// A11y will depend on the type of overlay and is arranged on contentNode level.
|
|
599
600
|
// Also see: https://www.scottohara.me/blog/2019/03/05/open-dialog.html
|
|
@@ -625,7 +626,6 @@ export class OverlayController extends EventTarget {
|
|
|
625
626
|
contentWrapperNodeL2: this.contentWrapperNode,
|
|
626
627
|
contentNodeL3: this.contentNode,
|
|
627
628
|
});
|
|
628
|
-
// @ts-expect-error
|
|
629
629
|
wrappingDialogElement.open = true;
|
|
630
630
|
|
|
631
631
|
if (this.isTooltip) {
|
|
@@ -911,7 +911,7 @@ export class OverlayController extends EventTarget {
|
|
|
911
911
|
this.contentNode.getRootNode()
|
|
912
912
|
).activeElement;
|
|
913
913
|
|
|
914
|
-
if (this.manager) {
|
|
914
|
+
if (this.manager && this.#isRegisteredOnManager()) {
|
|
915
915
|
this.manager.hide(this);
|
|
916
916
|
}
|
|
917
917
|
|
|
@@ -1145,65 +1145,97 @@ export class OverlayController extends EventTarget {
|
|
|
1145
1145
|
}
|
|
1146
1146
|
}
|
|
1147
1147
|
|
|
1148
|
-
get hasActiveTrapsKeyboardFocus() {
|
|
1149
|
-
return this.__hasActiveTrapsKeyboardFocus;
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
1148
|
/**
|
|
1153
|
-
* @param {
|
|
1154
|
-
* @protected
|
|
1149
|
+
* @param {KeyboardEvent} event
|
|
1155
1150
|
*/
|
|
1156
|
-
|
|
1157
|
-
if (
|
|
1158
|
-
|
|
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;
|
|
1170
1154
|
}
|
|
1171
|
-
}
|
|
1155
|
+
};
|
|
1172
1156
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
if (
|
|
1178
|
-
this
|
|
1157
|
+
/**
|
|
1158
|
+
* @param {KeyboardEvent} event
|
|
1159
|
+
*/
|
|
1160
|
+
#isShiftPressedOnKeyUpHandler = event => {
|
|
1161
|
+
if (event.key === 'Shift') {
|
|
1162
|
+
this.#isShiftPressed = false;
|
|
1179
1163
|
}
|
|
1164
|
+
};
|
|
1180
1165
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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
|
+
};
|
|
1188
1170
|
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1171
|
+
#stopHandlingShiftKeyPress = () => {
|
|
1172
|
+
window.removeEventListener('keydown', this.#isShiftPressedOnKeyDownHandler);
|
|
1173
|
+
window.removeEventListener('keyup', this.#isShiftPressedOnKeyUpHandler);
|
|
1174
|
+
};
|
|
1195
1175
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
+
}
|
|
1199
1222
|
}
|
|
1200
|
-
if (
|
|
1201
|
-
this
|
|
1202
|
-
this
|
|
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();
|
|
1203
1236
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
this.manager.informTrapsKeyboardFocusGotDisabled({ disabledCtrl: this, findNewTrap });
|
|
1237
|
+
if (phase === 'hide') {
|
|
1238
|
+
this.#stopHandlingShiftKeyPress();
|
|
1207
1239
|
}
|
|
1208
1240
|
}
|
|
1209
1241
|
|
|
@@ -1231,12 +1263,7 @@ export class OverlayController extends EventTarget {
|
|
|
1231
1263
|
return;
|
|
1232
1264
|
}
|
|
1233
1265
|
|
|
1234
|
-
|
|
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)) {
|
|
1240
1267
|
this.__escKeyHandlerCalled = true;
|
|
1241
1268
|
this.hide();
|
|
1242
1269
|
// We could do event.stopPropagation() here, but we don't want to hide info for
|
|
@@ -1246,6 +1273,15 @@ export class OverlayController extends EventTarget {
|
|
|
1246
1273
|
}
|
|
1247
1274
|
}
|
|
1248
1275
|
|
|
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
|
+
|
|
1249
1285
|
/**
|
|
1250
1286
|
* @param {KeyboardEvent} event
|
|
1251
1287
|
* @returns {void}
|
|
@@ -1253,10 +1289,7 @@ export class OverlayController extends EventTarget {
|
|
|
1253
1289
|
#outsideEscKeyHandler = event => {
|
|
1254
1290
|
if (event.key !== 'Escape') return;
|
|
1255
1291
|
|
|
1256
|
-
|
|
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;
|
|
1260
1293
|
this.hide();
|
|
1261
1294
|
};
|
|
1262
1295
|
|
|
@@ -134,39 +134,6 @@ export class OverlaysManager {
|
|
|
134
134
|
return this.__siblingsInert;
|
|
135
135
|
}
|
|
136
136
|
|
|
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
|
-
|
|
170
137
|
/** PreventsScroll */
|
|
171
138
|
|
|
172
139
|
// eslint-disable-next-line class-methods-use-this
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/* eslint-disable no-new */
|
|
2
|
+
/* eslint-disable lit-a11y/no-autofocus */
|
|
2
3
|
import { OverlayController, overlays } from '@lion/ui/overlays.js';
|
|
3
4
|
import { mimicClick } from '@lion/ui/overlays-test-helpers.js';
|
|
4
5
|
import { sendKeys } from '@web/test-runner-commands';
|
|
@@ -18,7 +19,7 @@ import { isActiveElement } from '../../core/test-helpers/isActiveElement.js';
|
|
|
18
19
|
import { createShadowHost } from '../test-helpers/createShadowHost.js';
|
|
19
20
|
import { _adoptStyleUtils } from '../src/utils/adopt-styles.js';
|
|
20
21
|
import { simulateTab } from '../src/utils/simulate-tab.js';
|
|
21
|
-
import {
|
|
22
|
+
import { browserDetection } from '../../core/src/browserDetection.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* @typedef {import('../types/OverlayConfig.js').ViewportPlacement} ViewportPlacement
|
|
@@ -309,6 +310,42 @@ describe('OverlayController', () => {
|
|
|
309
310
|
|
|
310
311
|
expect(isRegisteredOnManager(ctrl)).to.be.false;
|
|
311
312
|
});
|
|
313
|
+
|
|
314
|
+
it('handles removal from DOM gracefully when overlay is open', async () => {
|
|
315
|
+
const contentNode = /** @type {HTMLElement} */ (fixtureSync(html`<div>my content</div>`));
|
|
316
|
+
const ctrl = new OverlayController({
|
|
317
|
+
placementMode: 'global',
|
|
318
|
+
contentNode,
|
|
319
|
+
});
|
|
320
|
+
await ctrl.show();
|
|
321
|
+
expect(ctrl.isShown).to.be.true;
|
|
322
|
+
expect(isRegisteredOnManager(ctrl)).to.be.true;
|
|
323
|
+
|
|
324
|
+
// Remove contentNode from DOM (simulates element being removed while open)
|
|
325
|
+
contentNode.parentElement?.removeChild(contentNode);
|
|
326
|
+
|
|
327
|
+
// Manually trigger teardown (normally happens via disconnectedCallback in OverlayMixin)
|
|
328
|
+
ctrl.teardown();
|
|
329
|
+
|
|
330
|
+
// Teardown should complete without errors
|
|
331
|
+
expect(isRegisteredOnManager(ctrl)).to.be.false;
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('does not throw when hide() is called after teardown() on a shown controller', async () => {
|
|
335
|
+
const ctrl = new OverlayController({
|
|
336
|
+
...withGlobalTestConfig(),
|
|
337
|
+
});
|
|
338
|
+
await ctrl.show();
|
|
339
|
+
expect(ctrl.isShown).to.be.true;
|
|
340
|
+
|
|
341
|
+
// teardown removes the controller from the manager
|
|
342
|
+
ctrl.teardown();
|
|
343
|
+
expect(isRegisteredOnManager(ctrl)).to.be.false;
|
|
344
|
+
|
|
345
|
+
// hide() should handle gracefully that the controller is no longer on the manager
|
|
346
|
+
await ctrl.hide();
|
|
347
|
+
expect(ctrl.isShown).to.be.false;
|
|
348
|
+
});
|
|
312
349
|
});
|
|
313
350
|
|
|
314
351
|
describe('Node Configuration', () => {
|
|
@@ -585,30 +622,57 @@ describe('OverlayController', () => {
|
|
|
585
622
|
|
|
586
623
|
describe('Feature Configuration', () => {
|
|
587
624
|
describe('trapsKeyboardFocus', () => {
|
|
588
|
-
it('
|
|
625
|
+
it('focuses the overlay on show', async () => {
|
|
589
626
|
const ctrl = new OverlayController({
|
|
590
627
|
...withGlobalTestConfig(),
|
|
591
628
|
trapsKeyboardFocus: true,
|
|
592
629
|
});
|
|
593
|
-
expect(ctrl.hasActiveTrapsKeyboardFocus).to.be.false;
|
|
594
|
-
|
|
595
630
|
await ctrl.show();
|
|
596
|
-
expect(ctrl.
|
|
631
|
+
expect(isActiveElement(ctrl.contentNode)).to.be.true;
|
|
597
632
|
});
|
|
598
633
|
|
|
599
|
-
it('
|
|
634
|
+
it('keeps focus within the overlay e.g. you can not tab out by accident', async () => {
|
|
635
|
+
const contentNode = /** @type {HTMLElement} */ (
|
|
636
|
+
await fixture(html` <div><input id="input1" /><input id="input2" /></div> `)
|
|
637
|
+
);
|
|
600
638
|
const ctrl = new OverlayController({
|
|
601
639
|
...withGlobalTestConfig(),
|
|
602
640
|
trapsKeyboardFocus: true,
|
|
641
|
+
contentNode,
|
|
603
642
|
});
|
|
604
643
|
await ctrl.show();
|
|
605
644
|
|
|
606
|
-
|
|
645
|
+
await fixture(html`<button>click me</button>`);
|
|
646
|
+
const input1 = ctrl.contentNode.querySelectorAll('input')[0];
|
|
647
|
+
const input2 = ctrl.contentNode.querySelectorAll('input')[1];
|
|
648
|
+
input2.focus();
|
|
649
|
+
await sendKeys({ press: 'Tab' });
|
|
650
|
+
|
|
651
|
+
if (browserDetection.isChrome) {
|
|
652
|
+
expect(isActiveElement(document.body)).to.be.true;
|
|
653
|
+
await sendKeys({ press: 'Tab' });
|
|
654
|
+
expect(isActiveElement(input1)).to.be.true;
|
|
655
|
+
}
|
|
656
|
+
if (browserDetection.isFirefox) {
|
|
657
|
+
// For some reason with the headless firefox setup,
|
|
658
|
+
// once we focus on the latest focusable element, `input2` in this case,
|
|
659
|
+
// any further Tab presses keeps the focus on `input2` element
|
|
660
|
+
expect(isActiveElement(input2)).to.be.true;
|
|
661
|
+
await sendKeys({ press: 'Tab' });
|
|
662
|
+
expect(isActiveElement(input2)).to.be.true;
|
|
663
|
+
}
|
|
664
|
+
if (browserDetection.isMacSafari) {
|
|
665
|
+
expect(isActiveElement(document.body)).to.be.true;
|
|
666
|
+
await sendKeys({ press: 'Tab' });
|
|
667
|
+
expect(isActiveElement(ctrl.contentNode)).to.be.true;
|
|
668
|
+
await sendKeys({ press: 'Tab' });
|
|
669
|
+
expect(isActiveElement(input1)).to.be.true;
|
|
670
|
+
}
|
|
607
671
|
});
|
|
608
672
|
|
|
609
|
-
it('
|
|
673
|
+
it('focuses the element with [autofocus] when dialog gets opened', async () => {
|
|
610
674
|
const contentNode = /** @type {HTMLElement} */ (
|
|
611
|
-
await fixture(html` <div><input id="input1" /><input id="input2" /></div> `)
|
|
675
|
+
await fixture(html` <div><input id="input1" /><input id="input2" autofocus /></div> `)
|
|
612
676
|
);
|
|
613
677
|
const ctrl = new OverlayController({
|
|
614
678
|
...withGlobalTestConfig(),
|
|
@@ -617,21 +681,21 @@ describe('OverlayController', () => {
|
|
|
617
681
|
});
|
|
618
682
|
await ctrl.show();
|
|
619
683
|
|
|
620
|
-
const elOutside = /** @type {HTMLElement} */ (
|
|
621
|
-
await fixture(html`<button>click me</button>`)
|
|
622
|
-
);
|
|
623
|
-
const input1 = ctrl.contentNode.querySelectorAll('input')[0];
|
|
624
684
|
const input2 = ctrl.contentNode.querySelectorAll('input')[1];
|
|
685
|
+
expect(isActiveElement(input2)).to.be.true;
|
|
686
|
+
});
|
|
625
687
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
688
|
+
it('focuses the contentNode when dialog gets opened and there is no [autofocus]', async () => {
|
|
689
|
+
const contentNode = /** @type {HTMLElement} */ (
|
|
690
|
+
await fixture(html` <div><input id="input1" /><input id="input2" /></div> `)
|
|
691
|
+
);
|
|
692
|
+
const ctrl = new OverlayController({
|
|
693
|
+
...withGlobalTestConfig(),
|
|
694
|
+
trapsKeyboardFocus: true,
|
|
695
|
+
contentNode,
|
|
696
|
+
});
|
|
697
|
+
await ctrl.show();
|
|
698
|
+
expect(isActiveElement(contentNode)).to.be.true;
|
|
635
699
|
});
|
|
636
700
|
|
|
637
701
|
it('allows to move the focus outside of the overlay if trapsKeyboardFocus is disabled', async () => {
|
|
@@ -668,12 +732,10 @@ describe('OverlayController', () => {
|
|
|
668
732
|
|
|
669
733
|
await ctrl0.show();
|
|
670
734
|
await ctrl1.show();
|
|
671
|
-
expect(
|
|
672
|
-
expect(ctrl1.hasActiveTrapsKeyboardFocus).to.be.true;
|
|
735
|
+
expect(isActiveElement(ctrl1.contentNode)).to.be.true;
|
|
673
736
|
|
|
674
737
|
await ctrl1.hide();
|
|
675
|
-
expect(ctrl0.
|
|
676
|
-
expect(ctrl1.hasActiveTrapsKeyboardFocus).to.be.false;
|
|
738
|
+
expect(isActiveElement(ctrl0.contentNode)).to.be.true;
|
|
677
739
|
});
|
|
678
740
|
|
|
679
741
|
it('warns when contentNode is a host for a shadowRoot', async () => {
|
|
@@ -812,10 +874,6 @@ describe('OverlayController', () => {
|
|
|
812
874
|
);
|
|
813
875
|
const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
|
|
814
876
|
await mimicEscapePress(childOverlay.contentNode);
|
|
815
|
-
|
|
816
|
-
// without this line, the test is unstable on FF sometimes
|
|
817
|
-
await aTimeout(100);
|
|
818
|
-
|
|
819
877
|
await waitUntil(() => !parentOverlay.isShown);
|
|
820
878
|
await waitUntil(() => childOverlay.isShown);
|
|
821
879
|
|
|
@@ -597,6 +597,64 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) {
|
|
|
597
597
|
expect(isCtrlRegisteredAtOverlaysManager).to.equal(true);
|
|
598
598
|
});
|
|
599
599
|
|
|
600
|
+
it('teardown completes successfully when open overlay is removed from DOM', async () => {
|
|
601
|
+
const el = /** @type {OverlayEl} */ (
|
|
602
|
+
await fixture(html`
|
|
603
|
+
<${tag}>
|
|
604
|
+
<div slot="content">content of the overlay</div>
|
|
605
|
+
<button slot="invoker">invoker button</button>
|
|
606
|
+
</${tag}>
|
|
607
|
+
`)
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
// 1. Open the overlay
|
|
611
|
+
el.opened = true;
|
|
612
|
+
await el.updateComplete;
|
|
613
|
+
await el._overlayCtrl._showComplete;
|
|
614
|
+
expect(el._overlayCtrl.isShown).to.be.true;
|
|
615
|
+
|
|
616
|
+
// 2. Remove element from DOM without closing
|
|
617
|
+
/** @type {HTMLElement} */ (el.parentNode).removeChild(el);
|
|
618
|
+
|
|
619
|
+
// 3. Wait for _isPermanentlyDisconnected to resolve and teardown to complete
|
|
620
|
+
await aTimeout(0);
|
|
621
|
+
|
|
622
|
+
// 4. Teardown should complete without errors
|
|
623
|
+
// The controller should no longer be registered
|
|
624
|
+
expect(el._overlayCtrl.manager.list.find(ctrl => ctrl === el._overlayCtrl)).to.be.undefined;
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('does not throw when element is removed from DOM while overlay is open', async () => {
|
|
628
|
+
const el = /** @type {OverlayEl} */ (
|
|
629
|
+
await fixture(html`
|
|
630
|
+
<${tag}>
|
|
631
|
+
<div slot="content">content of the overlay</div>
|
|
632
|
+
<button slot="invoker">invoker button</button>
|
|
633
|
+
</${tag}>
|
|
634
|
+
`)
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
// 1. Open the overlay
|
|
638
|
+
el.opened = true;
|
|
639
|
+
await el.updateComplete;
|
|
640
|
+
await el._overlayCtrl._showComplete;
|
|
641
|
+
expect(el._overlayCtrl.isShown).to.be.true;
|
|
642
|
+
|
|
643
|
+
// 2. Store reference to the controller before removal
|
|
644
|
+
const ctrl = el._overlayCtrl;
|
|
645
|
+
|
|
646
|
+
// 3. Remove element from DOM without closing the overlay
|
|
647
|
+
/** @type {HTMLElement} */ (el.parentNode).removeChild(el);
|
|
648
|
+
|
|
649
|
+
// 4. Wait for _isPermanentlyDisconnected to resolve and teardown to complete
|
|
650
|
+
await aTimeout(0);
|
|
651
|
+
|
|
652
|
+
// 5. Teardown removed the controller from the manager.
|
|
653
|
+
// hide() should handle this gracefully without throwing.
|
|
654
|
+
await ctrl.hide();
|
|
655
|
+
expect(ctrl.isShown).to.be.false;
|
|
656
|
+
});
|
|
657
|
+
|
|
600
658
|
it('correctly removes event listeners when disconnected from dom', async () => {
|
|
601
659
|
const el = /** @type {OverlayEl} */ (
|
|
602
660
|
await fixture(html`
|