@lion/ui 0.18.0-menu-system.1 → 0.18.0-menu-system.4

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 (76) hide show
  1. package/components/menu/src/InteractiveListMixin.js +1 -1
  2. package/components/menu/src/MoreButtonMenuMixin.js +82 -92
  3. package/components/menu/src/MultiLevelListMixin.js +1 -1
  4. package/components/navigation-bar/src/LionNavigationBar.js +2 -2
  5. package/components/overlays/src/OverlayController.js +49 -14
  6. package/components/overlays/test/OverlayController.test.js +122 -11
  7. package/components/overlays/types/ArrowMixinTypes.ts +3 -1
  8. package/components/overlays/types/OverlayConfig.ts +5 -1
  9. package/custom-elements.json +113 -15
  10. package/dist-types/components/dialog/src/LionDialog.d.ts +3 -1
  11. package/dist-types/components/dialog/src/LionDialog.d.ts.map +1 -1
  12. package/dist-types/components/menu/src/LionMenuOverlay.d.ts +3 -1
  13. package/dist-types/components/menu/src/LionMenuOverlay.d.ts.map +1 -1
  14. package/dist-types/components/menu/src/MoreButtonMenuMixin.d.ts +11 -2
  15. package/dist-types/components/menu/src/MoreButtonMenuMixin.d.ts.map +1 -1
  16. package/dist-types/components/navigation-bar/src/LionNavigationBar.d.ts +4 -4
  17. package/dist-types/components/overlays/src/OverlayController.d.ts +16 -0
  18. package/dist-types/components/overlays/src/OverlayController.d.ts.map +1 -1
  19. package/dist-types/components/overlays/types/ArrowMixinTypes.d.ts.map +1 -1
  20. package/dist-types/components/overlays/types/OverlayConfig.d.ts +3 -1
  21. package/dist-types/components/overlays/types/OverlayConfig.d.ts.map +1 -1
  22. package/dist-types/components/select-rich/src/LionSelectRich.d.ts +3 -1
  23. package/dist-types/components/select-rich/src/LionSelectRich.d.ts.map +1 -1
  24. package/dist-types/tsconfig.tsbuildinfo +1 -1
  25. package/docs/components/checkbox-group/overview.md +2 -2
  26. package/docs/components/collapsible/overview.md +1 -1
  27. package/docs/components/combobox/overview.md +1 -1
  28. package/docs/components/combobox/use-cases.md +1 -1
  29. package/docs/components/dialog/overview.md +1 -1
  30. package/docs/components/fieldset/overview.md +5 -5
  31. package/docs/components/form/overview.md +3 -3
  32. package/docs/components/form/use-cases.md +2 -2
  33. package/docs/components/icon/use-cases.md +1 -1
  34. package/docs/components/input/overview.md +1 -1
  35. package/docs/components/input-amount/overview.md +3 -3
  36. package/docs/components/input-date/overview.md +3 -3
  37. package/docs/components/input-datepicker/overview.md +5 -5
  38. package/docs/components/input-email/overview.md +2 -2
  39. package/docs/components/input-file/overview.md +1 -1
  40. package/docs/components/input-iban/overview.md +2 -2
  41. package/docs/components/input-range/overview.md +2 -2
  42. package/docs/components/input-stepper/overview.md +1 -1
  43. package/docs/components/input-tel/overview.md +1 -1
  44. package/docs/components/input-tel/use-cases.md +6 -6
  45. package/docs/components/input-tel-dropdown/overview.md +1 -1
  46. package/docs/components/listbox/use-cases.md +1 -1
  47. package/docs/components/menu/extensions.md +27 -0
  48. package/docs/components/radio-group/overview.md +3 -3
  49. package/docs/components/select/use-cases.md +1 -1
  50. package/docs/components/select-rich/use-cases.md +2 -2
  51. package/docs/components/tabs/examples.md +1 -1
  52. package/docs/fundamentals/index.md +4 -4
  53. package/docs/fundamentals/node-tools/publish-docs/overview.md +3 -3
  54. package/docs/fundamentals/node-tools/rocket-preset-extend-lion-docs/overview.md +2 -2
  55. package/docs/fundamentals/rationales/accessibility.md +1 -1
  56. package/docs/fundamentals/systems/core/SlotMixin.md +1 -1
  57. package/docs/fundamentals/systems/core/overview.md +1 -1
  58. package/docs/fundamentals/systems/form/formatting-and-parsing.md +4 -4
  59. package/docs/fundamentals/systems/form/model-value.md +1 -1
  60. package/docs/fundamentals/systems/form/overview.md +28 -28
  61. package/docs/fundamentals/systems/form/validate.md +2 -2
  62. package/docs/fundamentals/systems/localize/overview.md +4 -4
  63. package/docs/fundamentals/systems/overlays/api-table.md +36 -34
  64. package/docs/fundamentals/systems/overlays/configuration.md +2 -2
  65. package/docs/fundamentals/systems/overlays/overview.md +5 -5
  66. package/docs/fundamentals/systems/overlays/use-cases.md +2 -2
  67. package/docs/fundamentals/tools/helpers/overview.md +1 -1
  68. package/docs/fundamentals/tools/singleton-manager/overview.md +4 -4
  69. package/docs/guides/how-to/create-a-custom-field.md +5 -5
  70. package/docs/guides/how-to/create-a-form.md +4 -4
  71. package/docs/guides/how-to/extend-a-native-input.md +2 -2
  72. package/docs/guides/how-to/index.md +1 -1
  73. package/docs/guides/index.md +8 -8
  74. package/docs/guides/principles/index.md +5 -5
  75. package/docs/guides/principles/styling.md +1 -1
  76. package/package.json +1 -1
@@ -457,7 +457,7 @@ const InteractiveListMixinImplementation = superclass =>
457
457
  * menuitemradios or -checkboxes.
458
458
  * When no role at all, we might deal with a wrapped node (for instance triggering a
459
459
  * submenu)
460
- * Alternatively, when _activeMode is ['disclosure'](https://www.w3.org/TR/wai-aria-practices-1.1/examples/disclosure/disclosure-navigation.html),
460
+ * Alternatively, when _activeMode is ['tabbable-disclosure'](https://www.w3.org/TR/wai-aria-practices-1.1/examples/disclosure/disclosure-navigation.html),
461
461
  * we search for a[href]
462
462
  */
463
463
  if (node.getAttribute('role') === 'group') {
@@ -1,62 +1,31 @@
1
- // @ts-nocheck
2
- /**
3
- * @param {any} superclass
4
- */
5
- /** @type {any} */
6
-
7
- const styles = `
8
- <style>
9
- /** More button is hidden by default */
10
- [data-more-button-wrapper] {
11
- display: none;
12
- }
13
-
14
- [data-more-button-menu]:has([role='listitem'] [active]:focus):not(
15
- :has([role='listitem'] [active][aria-expanded='true'])
16
- ) {
17
- overflow: visible;
18
- width: auto;
19
- height: auto;
20
- position: absolute;
21
- top: 100%;
22
- background-color: var(--oj2_bg_default);
23
- }
24
- [data-more-button-menu],
25
- [data-more-button-menu]:has([role='listitem'] [active][aria-expanded='true']) {
26
- overflow: hidden;
27
- width: 1px;
28
- height: 1px;
29
- }
30
-
31
- :host([responsive-mode='desktop']) [level='1'] > [slot='list'] > [role='listitem'] > button,
32
- :host([responsive-mode='desktop']) [level='1'] > [slot='list'] > [role='listitem'] > a,
33
- :host([responsive-mode='desktop'])
34
- [level='1']
35
- > [slot='list']
36
- > [data-more-button-wrapper]
37
- > [role='listitem']
38
- > button,
39
- :host([responsive-mode='desktop'])
40
- [level='1']
41
- > [slot='list']
42
- > [data-more-button-wrapper]
43
- > [role='listitem']
44
- > a {
45
- white-space: nowrap;
46
- }
47
- /**
48
- * TODO listitem rigth margin/gap is not taken at the account ATM
49
- */
50
- /**
51
- * More button relies on the fact that the menu items do not break the line
52
- * TODO is it optimal?
53
- */
54
- :host([responsive-mode='desktop']) > [role='list'] > [role='listitem'] > button,
55
- :host([responsive-mode='desktop']) > [role='list'] > [role='listitem'] > a {
56
- white-space: nowrap;
57
- }
58
- </style>
59
- `;
1
+ // eslint-disable-next-line import/no-extraneous-dependencies
2
+ import { css } from 'lit';
3
+
4
+ const styles = css`
5
+ /** More button is hidden by default */
6
+ [data-more-button-wrapper] {
7
+ display: none;
8
+ }
9
+
10
+ [data-more-button-menu][data-open]:not(:has([role='listitem'] [aria-expanded='true'])) {
11
+ position: absolute;
12
+ overflow: visible;
13
+ height: auto;
14
+ width: auto;
15
+ top: 100%;
16
+ }
17
+
18
+ /** N.B. this is a simplified version of sr-only. when we move to overlayctrl, we should build this into overlayctrl */
19
+ [data-more-button-menu] {
20
+ overflow: hidden;
21
+ height: 1px;
22
+ width: 1px;
23
+ }
24
+
25
+ [role='list']:has(> [data-more-button-wrapper]) > [role='listitem'] > :is(button, a) {
26
+ white-space: nowrap;
27
+ }
28
+ `;
60
29
 
61
30
  // @ts-ignore - JS mixin typing
62
31
  export const MoreButtonMenuMixin = superclass =>
@@ -85,7 +54,7 @@ export const MoreButtonMenuMixin = superclass =>
85
54
  // eslint-disable-next-line class-methods-use-this
86
55
  _getDeepActiveElement() {
87
56
  let host = document.activeElement || document.body;
88
- while (host && host.shadowRoot && host.shadowRoot.activeElement) {
57
+ while (host?.shadowRoot?.activeElement) {
89
58
  host = host.shadowRoot.activeElement;
90
59
  }
91
60
  return host;
@@ -94,7 +63,7 @@ export const MoreButtonMenuMixin = superclass =>
94
63
  _createMoreButtonWrapper() {
95
64
  const moreButtonWrapper = document.createElement('div');
96
65
  const style = document.createElement('style');
97
- style.textContent = styles;
66
+ style.textContent = styles.cssText;
98
67
  moreButtonWrapper.appendChild(style);
99
68
  moreButtonWrapper.setAttribute('data-more-button-wrapper', '');
100
69
  [...this.getMoreButtonSlotProjection().childNodes].forEach(node =>
@@ -107,32 +76,38 @@ export const MoreButtonMenuMixin = superclass =>
107
76
  const moreButtonMenu = document.createElement('div');
108
77
  moreButtonMenu.setAttribute('data-more-button-menu', '');
109
78
  moreButtonWrapper.appendChild(moreButtonMenu);
110
- moreButtonMenu.setAttribute('role', 'none');
111
79
  this._listNode.appendChild(moreButtonWrapper);
112
80
 
113
- const isElementDirectFocusableItemUnderMoreButtonMenu = element => {
114
- const targetTagName = element?.tagName;
115
- return (
116
- element?.parentElement?.parentElement === moreButtonMenu &&
117
- (targetTagName === 'BUTTON' || targetTagName === 'A')
118
- );
119
- };
81
+ const isElementDirectFocusableItemUnderMoreButtonMenu = (/** @type {Element} */ element) =>
82
+ this.listItems.includes(element) && moreButtonMenu.contains(element);
120
83
 
121
- moreButtonMenu.addEventListener('focusin', event => {
122
- const target = event?.target;
123
- if (isElementDirectFocusableItemUnderMoreButtonMenu(target)) {
84
+ moreButtonMenu.addEventListener(
85
+ 'focusin',
86
+ /** @type {FocusEvent} */ event => {
87
+ const target = /** @type {HTMLElement} */ (event?.target);
88
+ if (!isElementDirectFocusableItemUnderMoreButtonMenu(target)) return;
89
+
90
+ // A user did `Shift + Tab` from L2 menu to More menu
124
91
  if (target?.getAttribute('aria-expanded') === 'true') {
92
+ // close L2 menu
125
93
  target?.click();
126
94
  }
127
95
  moreButton?.setAttribute('aria-expanded', 'true');
128
- }
129
- });
96
+ moreButtonMenu.setAttribute('data-open', '');
97
+ },
98
+ );
130
99
 
131
- moreButtonMenu.addEventListener('focusout', event => {
132
- const target = event?.target;
133
- if (isElementDirectFocusableItemUnderMoreButtonMenu(target)) {
100
+ moreButtonMenu.addEventListener('focusout', () => {
101
+ // We need this timeout for Safari/WebKit.
102
+ // Unfortunately, an in progress click gets canceled when overflow:hidden is applied.
103
+ // Although mousedown would work instead, the consuming developer should have the freedom to apply all possible interaction pattterns.
104
+ // Especially, we should be compatible with 'withClickInteraction'
105
+ setTimeout(() => {
106
+ if (isElementDirectFocusableItemUnderMoreButtonMenu(this._getDeepActiveElement())) return;
134
107
  moreButton?.setAttribute('aria-expanded', 'false');
135
- }
108
+
109
+ moreButtonMenu.removeAttribute('data-open');
110
+ }, 100);
136
111
  });
137
112
 
138
113
  this.hasMoreButtonMenuAnyFocusedFirstLevelItems = false;
@@ -140,22 +115,26 @@ export const MoreButtonMenuMixin = superclass =>
140
115
  moreButton?.addEventListener('mousedown', () => {
141
116
  this.hasMoreButtonMenuAnyFocusedFirstLevelItems = false;
142
117
  const focusableFirstLevelItems = moreButtonMenu.querySelectorAll(
143
- ':scope > [role="listitem"] > a, :scope > [role="listitem"] > button',
118
+ ':scope > [role="listitem"] > :is(a, button)',
144
119
  );
145
120
 
146
- [...focusableFirstLevelItems]?.forEach(focusableFirstLevelItem => {
121
+ // @ts-ignore Type 'NodeListOf<Element>' must have a '[Symbol.iterator]()' method that returns an iterator.
122
+ for (const focusableFirstLevelItem of focusableFirstLevelItems) {
147
123
  if (this._getDeepActiveElement() === focusableFirstLevelItem) {
148
124
  this.hasMoreButtonMenuAnyFocusedFirstLevelItems = true;
149
125
  }
150
- });
126
+ }
151
127
  });
152
128
 
153
129
  moreButton?.addEventListener('click', () => {
154
- if (!this.hasMoreButtonMenuAnyFocusedFirstLevelItems) {
155
- moreButtonMenu
156
- .querySelector(':scope > [role="listitem"] > a, :scope > [role="listitem"] > button')
157
- ?.focus();
158
- }
130
+ if (this.hasMoreButtonMenuAnyFocusedFirstLevelItems) return;
131
+ /** @type {HTMLElement | null} */
132
+ const firstInteractiveItem = moreButtonMenu.querySelector(
133
+ ':scope > [role="listitem"] > :is(a, button)',
134
+ );
135
+
136
+ firstInteractiveItem?.focus();
137
+ moreButtonMenu.setAttribute('data-open', '');
159
138
  });
160
139
  }
161
140
 
@@ -232,13 +211,13 @@ export const MoreButtonMenuMixin = superclass =>
232
211
  return fragment;
233
212
  };
234
213
 
235
- moveItemToMoreButtonMenuFromMainMenu() {
236
- const moreButtonMenuElement = this.getMoreButtonMenu();
237
- const listItems = this._listNode.querySelectorAll(':scope > [role="listitem"]');
238
- const listItem = listItems[listItems.length - 1];
239
-
214
+ /**
215
+ * @param {{ moreButtonMenuElement: HTMLButtonElement, listItem: HTMLElement }} opts
216
+ */
217
+ moveItemToMoreButtonMenuFromMainMenu({ moreButtonMenuElement, listItem }) {
240
218
  const fragment = this.getListItemWithAdjacentNodes(listItem);
241
219
  moreButtonMenuElement.prepend(fragment);
220
+ // eslint-disable-next-line no-param-reassign
242
221
  listItem.style.display = '';
243
222
  this.displayMoreButton();
244
223
  }
@@ -268,9 +247,15 @@ export const MoreButtonMenuMixin = superclass =>
268
247
  return hiddenItemsCount;
269
248
  }
270
249
 
250
+ /**
251
+ * @param {number} hiddenItemsCount
252
+ */
271
253
  moveHiddenItemsFromMainMenuToMoreButtonMenu(hiddenItemsCount) {
254
+ const moreButtonMenuElement = this.getMoreButtonMenu();
255
+ const listItems = this._listNode.querySelectorAll(':scope > [role="listitem"]');
272
256
  for (let i = 0; i < hiddenItemsCount; i += 1) {
273
- this.moveItemToMoreButtonMenuFromMainMenu();
257
+ const listItem = listItems[listItems.length - 1 - i];
258
+ this.moveItemToMoreButtonMenuFromMainMenu({ moreButtonMenuElement, listItem });
274
259
  }
275
260
  }
276
261
 
@@ -279,6 +264,8 @@ export const MoreButtonMenuMixin = superclass =>
279
264
  return;
280
265
  }
281
266
 
267
+ this.style.visibility = 'hidden';
268
+
282
269
  this.__resizeTimeout = setTimeout(() => {
283
270
  this.__resizeTimeout = null;
284
271
 
@@ -286,12 +273,15 @@ export const MoreButtonMenuMixin = superclass =>
286
273
  this.hideMoreButton();
287
274
 
288
275
  if (this.doItemsFit()) {
276
+ this.style.visibility = '';
289
277
  return;
290
278
  }
291
279
 
280
+ this.style.visibility = 'hidden';
292
281
  this.displayMoreButton();
293
282
  const hiddenItemsCount = this.hideItemsOneByOneInMainMenuUntilTheyFit();
294
283
  this.moveHiddenItemsFromMainMenuToMoreButtonMenu(hiddenItemsCount);
284
+ this.style.visibility = '';
295
285
  }, 50);
296
286
  }
297
287
  };
@@ -214,7 +214,7 @@ const MultiLevelListMixinImplementation = superclass =>
214
214
  if (
215
215
  this.orientation === 'horizontal' &&
216
216
  subListOfActiveItem &&
217
- this._activeMode !== 'disclosure'
217
+ this._activeMode !== 'tabbable-disclosure'
218
218
  ) {
219
219
  this.activeItem.click();
220
220
  }
@@ -199,7 +199,7 @@ export class LionNavigationBar extends ScopedElementsMixin(LitElement) {
199
199
  openableConfig: {
200
200
  // We want to do the absolute positioning ourselves, popper is not suited for this...
201
201
  placementMode: 'custom',
202
- isOpenable: false,
202
+ isActivated: false,
203
203
  hidesOnOutsideClick: false,
204
204
  inheritsReferenceWidth: 'none',
205
205
  },
@@ -231,7 +231,7 @@ export class LionNavigationBar extends ScopedElementsMixin(LitElement) {
231
231
  // We want disclosure behavior
232
232
  placementMode: 'custom',
233
233
  // N.B. we don't have an invoker...
234
- isOpenable: false,
234
+ isActivated: false,
235
235
  inheritsReferenceWidth: 'none',
236
236
  focusContentOnOpen: true,
237
237
  },
@@ -237,8 +237,7 @@ export class OverlayController extends EventTarget {
237
237
  placement: 'center',
238
238
  },
239
239
  zIndex: 9999,
240
- // TODO: rename to isActivated
241
- isOpenable: true,
240
+ isActivated: true,
242
241
  focusContentOnOpen: false,
243
242
  // This means that content will have visually-hidden / sr-only styles. This is handy for:
244
243
  // - hidden menus that should be "indexable" by screen readers (like the Links list in VO: https://support.apple.com/en-gb/guide/voiceover/mchlp2719/mac)
@@ -247,6 +246,11 @@ export class OverlayController extends EventTarget {
247
246
  // @ts-ignore - hideVisually is an extension property
248
247
  hideVisually: false,
249
248
  requireConnectedNodes: true,
249
+
250
+ // hides children overlays when parent is closed...
251
+ syncChildrenCloseState: false,
252
+ // // In next major, we remove this prop. Now we disable it for backwards compatibility and enable it in the places we need it internally
253
+ // _shouldTeardownDomStructure: false,
250
254
  };
251
255
 
252
256
  /** @protected */
@@ -272,7 +276,7 @@ export class OverlayController extends EventTarget {
272
276
  this.__escKeyHandlerCalled = false;
273
277
  }
274
278
 
275
- #hasSetup = false;
279
+ __hasSetup = false;
276
280
 
277
281
  /**
278
282
  * The invokerNode
@@ -540,7 +544,7 @@ export class OverlayController extends EventTarget {
540
544
  },
541
545
  };
542
546
 
543
- const shouldUpdate = !this.#hasSetup || !isEqualConfig(prevConfig, newConfig);
547
+ const shouldUpdate = !this.__hasSetup || !isEqualConfig(prevConfig, newConfig);
544
548
  if (!shouldUpdate) return;
545
549
  // Teardown all previous configs
546
550
 
@@ -592,7 +596,7 @@ export class OverlayController extends EventTarget {
592
596
  * @protected
593
597
  */
594
598
  _init() {
595
- if (!this.config.isOpenable) return;
599
+ if (!this.config.isActivated) return;
596
600
 
597
601
  // TODO: should not be behind a flag when we are fully tearing down...
598
602
  // TODO 2: When we move away from dialog (use popover to paint to top layer)
@@ -627,7 +631,7 @@ export class OverlayController extends EventTarget {
627
631
  // Use event delegation to listen for clicks on close buttons...
628
632
  this.contentNode.addEventListener('click', this.#hideOnCloseButtonClick);
629
633
 
630
- this.#hasSetup = true;
634
+ this.__hasSetup = true;
631
635
  }
632
636
 
633
637
  #hideOnCloseButtonClick = (/** @type {{ target: any; }} */ ev) => {
@@ -981,7 +985,7 @@ export class OverlayController extends EventTarget {
981
985
  */
982
986
  async hide() {
983
987
  // Function like a no-op for dynamic edge cases...
984
- if (!this.config.isOpenable) return;
988
+ if (!this.config.isActivated) return;
985
989
 
986
990
  this._hideComplete = new Promise(resolve => {
987
991
  this._hideResolve = resolve;
@@ -1135,24 +1139,53 @@ export class OverlayController extends EventTarget {
1135
1139
  if (this.visibilityTriggerFunction) {
1136
1140
  this._handleVisibilityTriggers({ phase });
1137
1141
  }
1138
-
1139
- if (phase === 'init' && this.config.focusContentOnOpen) {
1140
- this.contentNode?.setAttribute('tabindex', '-1');
1142
+ if (this.config.syncChildrenCloseState) {
1143
+ this._handleSyncChildrenCloseState({ phase });
1141
1144
  }
1145
+ if (this.config.focusContentOnOpen) {
1146
+ this._handleFocusContentOnOpen({ phase });
1147
+ }
1148
+ }
1142
1149
 
1143
- if (phase === 'teardown' && this.config.focusContentOnOpen) {
1150
+ /**
1151
+ * @param {{phase: OverlayPhase}} opts
1152
+ * @returns {void}
1153
+ */
1154
+ _handleFocusContentOnOpen({ phase }) {
1155
+ if (phase === 'init') {
1156
+ this.contentNode?.setAttribute('tabindex', '-1');
1157
+ } else if (phase === 'teardown') {
1144
1158
  // TODO: capture initial attr, use Resettable mechanism of VisibilityToggleCtrl in whole Controller
1145
1159
  this.contentNode?.removeAttribute('tabindex');
1146
1160
  }
1147
1161
  }
1148
1162
 
1163
+ /**
1164
+ * @param {{phase: OverlayPhase}} opts
1165
+ * @returns {void}
1166
+ */
1167
+ _handleSyncChildrenCloseState({ phase }) {
1168
+ if (phase !== 'hide') return;
1169
+
1170
+ const visibleChildren = this.manager.shownList.filter(
1171
+ ctrl => ctrl !== this && deepContains(this.contentNode, ctrl.contentNode),
1172
+ );
1173
+ visibleChildren.forEach(ctrl => ctrl.hide());
1174
+ }
1175
+
1149
1176
  /**
1150
1177
  * @param {{ phase: OverlayPhase }} config
1151
1178
  */
1152
1179
  _handleVisibilityTriggers({ phase }) {
1153
1180
  if (typeof this.visibilityTriggerFunction !== 'function') return;
1154
1181
 
1155
- this.visibilityTriggerFunction({ controller: this })?.[phase]?.();
1182
+ // Here we initialize the __visibilityTriggerHandler of our invokerNode. It's important that we ONLY do this on init,
1183
+ // otherwise we risk not being able to properly clean up listeners...
1184
+ if (phase === 'init') {
1185
+ this.__visibilityTriggerHandler = this.visibilityTriggerFunction({ controller: this });
1186
+ }
1187
+ // Here we run the appropriate lifecycle, if defined in our handler
1188
+ this.__visibilityTriggerHandler[phase]?.();
1156
1189
  }
1157
1190
 
1158
1191
  /**
@@ -1562,7 +1595,7 @@ export class OverlayController extends EventTarget {
1562
1595
  }
1563
1596
 
1564
1597
  teardown() {
1565
- if (!this.#hasSetup) return;
1598
+ if (!this.__hasSetup) return;
1566
1599
  this.__handleOverlayStyles({ phase: 'teardown' });
1567
1600
  if (this.isShown) {
1568
1601
  this._keepBodySize({ phase: 'teardown' });
@@ -1574,10 +1607,12 @@ export class OverlayController extends EventTarget {
1574
1607
 
1575
1608
  this.contentNode?.removeEventListener('click', this.#hideOnCloseButtonClick);
1576
1609
 
1610
+ // if (this.config._shouldTeardownDomStructure) {
1577
1611
  this.__rearrangeNodesCleanup?.();
1612
+ // }
1578
1613
  this.__teardownVisibility();
1579
1614
 
1580
- this.#hasSetup = false;
1615
+ this.__hasSetup = false;
1581
1616
  }
1582
1617
 
1583
1618
  /** @private */
@@ -115,26 +115,35 @@ const withLocalTestConfig = () =>
115
115
  */
116
116
  async function createNestedEscControllers(parentContent) {
117
117
  const childContent = /** @type {HTMLDivElement} */ (parentContent.querySelector('div[id]'));
118
- // Assert valid fixure
119
- const isValidFixture =
120
- (parentContent.id.startsWith('parent-overlay--hidesOnEsc') ||
121
- parentContent.id.startsWith('parent-overlay--hidesOnOutsideEsc')) &&
122
- (childContent.id.startsWith('child-overlay--hidesOnEsc') ||
123
- childContent.id.startsWith('child-overlay--hidesOnOutsideEsc'));
124
- if (!isValidFixture) {
125
- throw new Error('Provide a valid fixture');
126
- }
127
118
 
128
119
  if (parentContent.hasAttribute('data-convert-to-shadow-root')) {
129
120
  const shadowRootParent = parentContent.attachShadow({ mode: 'open' });
130
121
  shadowRootParent.appendChild(childContent);
131
122
  }
132
123
 
124
+ const parentConfig = {};
125
+ const childConfig = {};
126
+
133
127
  const parentHasOutsideOnEsc = parentContent.id.startsWith('parent-overlay--hidesOnOutsideEsc');
134
128
  const childHasOutsideOnEsc = childContent.id.startsWith('child-overlay--hidesOnOutsideEsc');
135
129
 
136
- const parentConfig = parentHasOutsideOnEsc ? { hidesOnOutsideEsc: true } : { hidesOnEsc: true };
137
- const childConfig = childHasOutsideOnEsc ? { hidesOnOutsideEsc: true } : { hidesOnEsc: true };
130
+ if (parentHasOutsideOnEsc) {
131
+ parentConfig.hidesOnOutsideEsc = true;
132
+ } else {
133
+ parentConfig.hidesOnEsc = true;
134
+ }
135
+ if (childHasOutsideOnEsc) {
136
+ childConfig.hidesOnOutsideEsc = true;
137
+ } else {
138
+ childConfig.hidesOnEsc = true;
139
+ }
140
+
141
+ const parentHasSyncChildrenCloseState =
142
+ parentContent.dataset.syncChildrenCloseState !== undefined;
143
+
144
+ if (parentHasSyncChildrenCloseState) {
145
+ parentConfig.syncChildrenCloseState = true;
146
+ }
138
147
 
139
148
  const parentOverlay = new OverlayController({
140
149
  ...withGlobalTestConfig(),
@@ -347,6 +356,21 @@ describe('OverlayController', () => {
347
356
  await ctrl.hide();
348
357
  expect(ctrl.isShown).to.be.false;
349
358
  });
359
+
360
+ // it('cleans up the dom structure it created', async () => {
361
+ // const contentNode = /** @type {HTMLElement} */ (fixtureSync(html`<div>my content</div>`));
362
+ // const ctrl = new OverlayController({
363
+ // placementMode: 'global',
364
+ // contentNode,
365
+ // });
366
+ // await ctrl.show();
367
+ // expect(ctrl.contentWrapperNode).to.exist;
368
+ // expect(ctrl.__wrappingDialogNode).to.exist;
369
+
370
+ // await ctrl.teardown();
371
+ // expect(ctrl.contentWrapperNode).to.not.exist;
372
+ // expect(ctrl.__wrappingDialogNode).to.not.exist;
373
+ // });
350
374
  });
351
375
 
352
376
  describe('Node Configuration', () => {
@@ -621,6 +645,7 @@ describe('OverlayController', () => {
621
645
  });
622
646
  });
623
647
 
648
+ // TODO: make features modular (so they can be treeshaken) and test their lifecycle within this desrcibe
624
649
  describe('Feature Configuration', () => {
625
650
  describe('trapsKeyboardFocus', () => {
626
651
  it('focuses the overlay on show', async () => {
@@ -983,6 +1008,7 @@ describe('OverlayController', () => {
983
1008
  });
984
1009
  });
985
1010
  });
1011
+
986
1012
  describe('hidesOnOutsideClick', () => {
987
1013
  it('hides on outside click', async () => {
988
1014
  const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
@@ -1703,6 +1729,91 @@ describe('OverlayController', () => {
1703
1729
  expect(ctrl1.hasActiveBackdrop).to.be.true;
1704
1730
  });
1705
1731
  });
1732
+
1733
+ describe('focusContentOnOpen', () => {
1734
+ it('adds tabindex="-1" to the content node when focusContentOnOpen is true', async () => {
1735
+ const ctrl = new OverlayController({
1736
+ ...withGlobalTestConfig(),
1737
+ isBlocking: false,
1738
+ focusContentOnOpen: true,
1739
+ });
1740
+ const contentNode = /** @type {HTMLElement} */ (await fixture('<div>Content</div>'));
1741
+ ctrl.updateConfig({ contentNode });
1742
+ await ctrl.show();
1743
+ expect(contentNode.getAttribute('tabindex')).to.equal('-1');
1744
+ });
1745
+
1746
+ it('makes contentNode the root of "next tab flow"', async () => {
1747
+ const ctrl = new OverlayController({
1748
+ ...withGlobalTestConfig(),
1749
+ isBlocking: false,
1750
+ focusContentOnOpen: true,
1751
+ });
1752
+ const contentNode = /** @type {HTMLElement} */ (
1753
+ await fixture('<div><button>Button</button></div>')
1754
+ );
1755
+ ctrl.updateConfig({ contentNode });
1756
+ await ctrl.show();
1757
+ const button = /** @type {HTMLButtonElement} */ (contentNode.querySelector('button'));
1758
+ button.focus();
1759
+ expect(isActiveElement(button)).to.be.true;
1760
+ });
1761
+ });
1762
+
1763
+ describe('syncChildrenCloseState', () => {
1764
+ it('does not close all children of nested controllers when syncChildrenCloseState is false', async () => {
1765
+ const parentContent = /** @type {HTMLDivElement} */ (
1766
+ await fixture(
1767
+ html` <!-- -->
1768
+ <div data-sync-children-close-state id="parent-overlay">
1769
+ <div id="child-overlay">we open our child and it will close when parent does</div>
1770
+ </div>`,
1771
+ )
1772
+ );
1773
+ const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
1774
+ expect(parentOverlay.isShown).to.be.true;
1775
+ expect(childOverlay.isShown).to.be.true;
1776
+
1777
+ await parentOverlay.hide();
1778
+ await childOverlay._hideComplete;
1779
+ expect(parentOverlay.isShown).to.be.false;
1780
+ expect(childOverlay.isShown).to.be.false;
1781
+ });
1782
+
1783
+ it('does not close all children of nested controllers when syncChildrenCloseState is false', async () => {
1784
+ const parentContent = /** @type {HTMLDivElement} */ (
1785
+ await fixture(
1786
+ html` <!-- -->
1787
+ <div id="parent-overlay">
1788
+ <div id="child-overlay">
1789
+ we open our child and it will not close when parent does
1790
+ </div>
1791
+ </div>`,
1792
+ )
1793
+ );
1794
+ const { parentOverlay, childOverlay } = await createNestedEscControllers(parentContent);
1795
+ expect(parentOverlay.isShown).to.be.true;
1796
+ expect(childOverlay.isShown).to.be.true;
1797
+
1798
+ await parentOverlay.hide();
1799
+ expect(parentOverlay.isShown).to.be.false;
1800
+ expect(childOverlay.isShown).to.be.true;
1801
+ });
1802
+ });
1803
+
1804
+ describe('isActivated', () => {
1805
+ it('allows to conditionally disable overlay/openable functionality (for responsive context like nav menus, accordions/collapsibles vs plain headings/content etc.)', async () => {
1806
+ const ctrl = new OverlayController({
1807
+ ...withGlobalTestConfig(),
1808
+ isActivated: false,
1809
+ });
1810
+ expect(ctrl.__hasSetup).to.be.false;
1811
+ ctrl.updateConfig({
1812
+ isActivated: true,
1813
+ });
1814
+ expect(ctrl.__hasSetup).to.be.true;
1815
+ });
1816
+ });
1706
1817
  });
1707
1818
 
1708
1819
  describe('Show / Hide / Toggle', () => {
@@ -26,7 +26,9 @@ export declare class ArrowHost {
26
26
 
27
27
  protected _defineOverlayConfig(): OverlayConfig;
28
28
 
29
- protected _getPopperArrowConfig(popperConfigToExtendFrom: Partial<PopperOptions>): Partial<PopperOptions>;
29
+ protected _getPopperArrowConfig(
30
+ popperConfigToExtendFrom: Partial<PopperOptions>,
31
+ ): Partial<PopperOptions>;
30
32
 
31
33
  private __setupRepositionCompletePromise(): void;
32
34
 
@@ -111,11 +111,15 @@ export interface OverlayConfig {
111
111
  * By default, we function either as overlay or disclosure. In some cases, this should be dynamically set (think of responsive changes).
112
112
  * or it depends on the context (a top level menu is not openable (unless it's a context menu), its children menus are).
113
113
  */
114
- isOpenable?: boolean;
114
+ isActivated?: boolean;
115
115
 
116
116
  /**
117
117
  * By default, an error is thrown when contentNode is not connected to the DOM.
118
118
  * This allows to prepare nodes and later connect them to dom.
119
119
  */
120
120
  requireConnectedNodes?: boolean;
121
+
122
+ syncChildrenCloseState?: boolean;
123
+
124
+ _shouldTeardownDomStructure?: boolean;
121
125
  }