@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.
Files changed (71) hide show
  1. package/components/overlays/src/OverlayController.js +100 -67
  2. package/components/overlays/src/OverlaysManager.js +0 -33
  3. package/components/overlays/test/OverlayController.test.js +88 -30
  4. package/components/overlays/test-suites/OverlayMixin.suite.js +58 -0
  5. package/custom-elements.json +105 -200
  6. package/dist-types/components/overlays/src/OverlayController.d.ts +1 -11
  7. package/dist-types/components/overlays/src/OverlayController.d.ts.map +1 -1
  8. package/dist-types/components/overlays/src/OverlaysManager.d.ts +0 -12
  9. package/dist-types/components/overlays/src/OverlaysManager.d.ts.map +1 -1
  10. package/dist-types/components/overlays/test/OverlayController.test.d.ts.map +1 -1
  11. package/dist-types/components/overlays/test-suites/OverlayMixin.suite.d.ts.map +1 -1
  12. package/dist-types/exports/overlays.d.ts +0 -1
  13. package/dist-types/tsconfig.tsbuildinfo +1 -1
  14. package/docs/components/checkbox-group/overview.md +2 -2
  15. package/docs/components/collapsible/overview.md +1 -1
  16. package/docs/components/combobox/overview.md +1 -1
  17. package/docs/components/combobox/use-cases.md +1 -1
  18. package/docs/components/dialog/overview.md +1 -1
  19. package/docs/components/fieldset/overview.md +5 -5
  20. package/docs/components/form/overview.md +3 -3
  21. package/docs/components/form/use-cases.md +2 -2
  22. package/docs/components/icon/use-cases.md +1 -1
  23. package/docs/components/input/overview.md +1 -1
  24. package/docs/components/input-amount/overview.md +3 -3
  25. package/docs/components/input-date/overview.md +3 -3
  26. package/docs/components/input-datepicker/overview.md +5 -5
  27. package/docs/components/input-email/overview.md +2 -2
  28. package/docs/components/input-file/overview.md +1 -1
  29. package/docs/components/input-iban/overview.md +2 -2
  30. package/docs/components/input-range/overview.md +2 -2
  31. package/docs/components/input-stepper/overview.md +1 -1
  32. package/docs/components/input-tel/overview.md +1 -1
  33. package/docs/components/input-tel/use-cases.md +6 -6
  34. package/docs/components/input-tel-dropdown/overview.md +1 -1
  35. package/docs/components/listbox/use-cases.md +1 -1
  36. package/docs/components/radio-group/overview.md +3 -3
  37. package/docs/components/select/use-cases.md +1 -1
  38. package/docs/components/select-rich/use-cases.md +2 -2
  39. package/docs/components/tabs/examples.md +1 -1
  40. package/docs/fundamentals/index.md +4 -4
  41. package/docs/fundamentals/node-tools/publish-docs/overview.md +3 -3
  42. package/docs/fundamentals/node-tools/rocket-preset-extend-lion-docs/overview.md +2 -2
  43. package/docs/fundamentals/rationales/accessibility.md +1 -1
  44. package/docs/fundamentals/systems/core/SlotMixin.md +1 -1
  45. package/docs/fundamentals/systems/core/overview.md +1 -1
  46. package/docs/fundamentals/systems/form/formatting-and-parsing.md +4 -4
  47. package/docs/fundamentals/systems/form/model-value.md +1 -1
  48. package/docs/fundamentals/systems/form/overview.md +28 -28
  49. package/docs/fundamentals/systems/form/validate.md +2 -2
  50. package/docs/fundamentals/systems/localize/overview.md +4 -4
  51. package/docs/fundamentals/systems/overlays/api-table.md +45 -51
  52. package/docs/fundamentals/systems/overlays/configuration.md +5 -2
  53. package/docs/fundamentals/systems/overlays/overview.md +5 -5
  54. package/docs/fundamentals/systems/overlays/use-cases.md +2 -2
  55. package/docs/fundamentals/tools/helpers/overview.md +1 -1
  56. package/docs/fundamentals/tools/singleton-manager/overview.md +4 -4
  57. package/docs/guides/how-to/create-a-custom-field.md +5 -5
  58. package/docs/guides/how-to/create-a-form.md +4 -4
  59. package/docs/guides/how-to/extend-a-native-input.md +2 -2
  60. package/docs/guides/index.md +8 -8
  61. package/docs/guides/principles/index.md +5 -5
  62. package/docs/guides/principles/styling.md +1 -1
  63. package/exports/overlays.js +0 -1
  64. package/overlays.js +0 -1
  65. package/package.json +1 -1
  66. package/components/overlays/src/utils/contain-focus.js +0 -202
  67. package/components/overlays/test/utils-tests/contain-focus.test.js +0 -236
  68. package/dist-types/components/overlays/src/utils/contain-focus.d.ts +0 -20
  69. package/dist-types/components/overlays/src/utils/contain-focus.d.ts.map +0 -1
  70. package/dist-types/components/overlays/test/utils-tests/contain-focus.test.d.ts +0 -2
  71. 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 {{ phase: OverlayPhase }} config
1154
- * @protected
1149
+ * @param {KeyboardEvent} event
1155
1150
  */
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;
1170
1154
  }
1171
- }
1155
+ };
1172
1156
 
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;
1179
1163
  }
1164
+ };
1180
1165
 
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
+ };
1188
1170
 
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
+ };
1195
1175
 
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
+ }
1199
1222
  }
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();
1203
1236
  }
1204
- this.__hasActiveTrapsKeyboardFocus = false;
1205
- if (this.manager) {
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
- 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)) {
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
- 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;
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 { keyCodes } from '../src/utils/key-codes.js';
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('offers an hasActiveTrapsKeyboardFocus flag', async () => {
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.hasActiveTrapsKeyboardFocus).to.be.true;
631
+ expect(isActiveElement(ctrl.contentNode)).to.be.true;
597
632
  });
598
633
 
599
- it('focuses the overlay on show', async () => {
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
- expect(isActiveElement(ctrl.contentNode)).to.be.true;
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('keeps focus within the overlay e.g. you can not tab out by accident', async () => {
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
- input2.focus();
627
- // this mimics a tab within the contain-focus system used
628
- const event = new CustomEvent('keydown', { detail: 0, bubbles: true });
629
- // @ts-ignore override private key
630
- event.keyCode = keyCodes.tab;
631
- window.dispatchEvent(event);
632
-
633
- expect(isActiveElement(elOutside)).to.be.false;
634
- expect(isActiveElement(input1)).to.be.true;
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(ctrl0.hasActiveTrapsKeyboardFocus).to.be.false;
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.hasActiveTrapsKeyboardFocus).to.be.true;
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`