@zeyos/zx 5.0.0 → 5.2.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 (154) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +2 -1
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +9 -1
  5. package/dist/types/components/app-icon/app-icon.d.ts +14 -2
  6. package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
  7. package/dist/types/components/calendar/calendar.d.ts +67 -5
  8. package/dist/types/components/card-view/card-view.d.ts +7 -4
  9. package/dist/types/components/card-view/record-card.d.ts +11 -0
  10. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  11. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  12. package/dist/types/components/dropdown/dropdown.d.ts +22 -7
  13. package/dist/types/components/field/field.d.ts +30 -0
  14. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  15. package/dist/types/components/fieldset/fieldset.d.ts +7 -0
  16. package/dist/types/components/file-list/file-list.d.ts +35 -3
  17. package/dist/types/components/filter/filter.d.ts +127 -3
  18. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  19. package/dist/types/components/launcher/launcher.d.ts +8 -0
  20. package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
  21. package/dist/types/components/menu-button/menu-button.d.ts +6 -0
  22. package/dist/types/components/message/message.d.ts +14 -1
  23. package/dist/types/components/modal/modal.d.ts +30 -0
  24. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  25. package/dist/types/components/pagination/pagination.d.ts +17 -3
  26. package/dist/types/components/select/select.d.ts +6 -0
  27. package/dist/types/components/sheet/sheet.d.ts +15 -0
  28. package/dist/types/components/table/table.d.ts +265 -17
  29. package/dist/types/components/view/record-view.d.ts +59 -2
  30. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  31. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  32. package/dist/types/core/component.d.ts +12 -2
  33. package/dist/types/core/i18n.d.ts +27 -3
  34. package/dist/types/core/keyboard.d.ts +98 -1
  35. package/dist/types/core/messages.d.ts +382 -0
  36. package/dist/types/core/overlay-host.d.ts +28 -1
  37. package/dist/types/core/overlay-stack.d.ts +260 -0
  38. package/dist/types/index.d.ts +5 -2
  39. package/dist/types/internal/app-rail.d.ts +38 -0
  40. package/dist/types/internal/menu-items.d.ts +71 -3
  41. package/dist/types/zeyos/index.d.ts +1 -0
  42. package/dist/types/zeyos/launcher.d.ts +9 -0
  43. package/dist/types/zeyos/module-bar.d.ts +176 -0
  44. package/dist/types/zeyos/query.d.ts +31 -5
  45. package/dist/types/zeyos/table.d.ts +245 -8
  46. package/dist/types/zeyos/view.d.ts +16 -2
  47. package/dist/zx-zeyos.esm.js +4422 -430
  48. package/dist/zx-zeyos.esm.min.js +5 -5
  49. package/dist/zx.css +1417 -134
  50. package/dist/zx.esm.js +6986 -830
  51. package/dist/zx.esm.min.js +8 -8
  52. package/dist/zx.global.js +6986 -830
  53. package/dist/zx.global.min.js +8 -8
  54. package/dist/zx.min.css +1 -1
  55. package/docs/api.json +794 -17
  56. package/docs/llms.md +503 -63
  57. package/docs/llms.txt +6 -2
  58. package/package.json +1 -1
  59. package/src/components/account-menu/account-menu.js +12 -0
  60. package/src/components/activity-list/activity-list.js +27 -1
  61. package/src/components/app-icon/app-icon.css +13 -2
  62. package/src/components/app-icon/app-icon.js +11 -1
  63. package/src/components/app-rail/app-rail.css +19 -0
  64. package/src/components/app-sidebar/app-sidebar.css +204 -3
  65. package/src/components/app-sidebar/app-sidebar.js +646 -40
  66. package/src/components/breadcrumb/breadcrumb.css +1 -1
  67. package/src/components/calendar/calendar.css +1 -1
  68. package/src/components/calendar/calendar.js +26 -52
  69. package/src/components/card/card.css +2 -2
  70. package/src/components/card-view/card-view.css +5 -5
  71. package/src/components/card-view/card-view.js +13 -9
  72. package/src/components/card-view/record-card.js +14 -3
  73. package/src/components/code-editor/code-editor.css +1 -1
  74. package/src/components/code-editor/field-adapter.js +9 -2
  75. package/src/components/context-menu/context-menu.js +8 -2
  76. package/src/components/copy/copy-input.css +1 -1
  77. package/src/components/data-filter/data-filter.js +2 -2
  78. package/src/components/date-picker/time-picker.css +2 -2
  79. package/src/components/dock/dock.css +5 -4
  80. package/src/components/dropdown/dropdown.js +157 -72
  81. package/src/components/field/field.js +33 -3
  82. package/src/components/fieldset/external-adapter.js +363 -0
  83. package/src/components/fieldset/fieldset.js +10 -59
  84. package/src/components/file-list/file-list.js +144 -21
  85. package/src/components/filter/filter.css +86 -2
  86. package/src/components/filter/filter.js +300 -8
  87. package/src/components/filter-panel/filter-panel.js +149 -32
  88. package/src/components/finder/finder.css +1 -1
  89. package/src/components/kanban-view/kanban-view.css +3 -3
  90. package/src/components/kanban-view/kanban-view.js +5 -2
  91. package/src/components/launcher/launcher.css +1 -1
  92. package/src/components/launcher/launcher.js +29 -3
  93. package/src/components/list-toolbar/list-toolbar.js +39 -13
  94. package/src/components/master-panel/master-panel.js +0 -1
  95. package/src/components/menu-button/menu-button.css +63 -0
  96. package/src/components/menu-button/menu-button.js +97 -11
  97. package/src/components/message/message.js +60 -20
  98. package/src/components/modal/modal.css +1 -1
  99. package/src/components/modal/modal.js +66 -13
  100. package/src/components/module-bar/module-bar.css +486 -0
  101. package/src/components/module-bar/module-bar.js +1074 -0
  102. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  103. package/src/components/number-field/field-adapter.js +2 -1
  104. package/src/components/pagination/pagination.css +5 -5
  105. package/src/components/pagination/pagination.js +20 -4
  106. package/src/components/panel/panel.css +3 -2
  107. package/src/components/questionnaire/questionnaire.css +4 -4
  108. package/src/components/rating/field-adapter.js +2 -1
  109. package/src/components/rating/rating.css +1 -1
  110. package/src/components/search/search.css +4 -1
  111. package/src/components/select/select.js +9 -0
  112. package/src/components/sheet/sheet.css +1 -1
  113. package/src/components/sheet/sheet.js +74 -32
  114. package/src/components/slider/field-adapter.js +6 -1
  115. package/src/components/slider/slider.css +2 -2
  116. package/src/components/split-view/split-view.css +1 -1
  117. package/src/components/stat-tile/stat-tile.css +1 -1
  118. package/src/components/stepper/stepper.css +1 -1
  119. package/src/components/tabbox/tabbox.css +9 -5
  120. package/src/components/table/table.css +86 -11
  121. package/src/components/table/table.js +628 -57
  122. package/src/components/tag-picker/field-adapter.js +2 -1
  123. package/src/components/tooltip/tooltip.js +35 -17
  124. package/src/components/tree/tree.css +1 -1
  125. package/src/components/view/record-view.css +1 -1
  126. package/src/components/view/record-view.js +178 -18
  127. package/src/components/view-settings/view-settings.css +285 -0
  128. package/src/components/view-settings/view-settings.js +1830 -0
  129. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  130. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  131. package/src/core/component.js +88 -17
  132. package/src/core/fontawesome.js +5 -1
  133. package/src/core/i18n.js +47 -4
  134. package/src/core/icons.js +12 -2
  135. package/src/core/keyboard.js +206 -11
  136. package/src/core/messages.js +427 -0
  137. package/src/core/overlay-host.js +64 -9
  138. package/src/core/overlay-stack.js +487 -0
  139. package/src/core/position.js +22 -3
  140. package/src/index.js +5 -2
  141. package/src/internal/app-rail.js +134 -53
  142. package/src/internal/menu-items.js +61 -3
  143. package/src/zeyos/form.js +5 -3
  144. package/src/zeyos/index.js +3 -0
  145. package/src/zeyos/launcher.js +8 -3
  146. package/src/zeyos/module-bar.js +181 -0
  147. package/src/zeyos/query.js +44 -13
  148. package/src/zeyos/table.js +220 -80
  149. package/src/zeyos/view.js +43 -93
  150. package/styles/base.css +16 -1
  151. package/styles/print.css +1 -1
  152. package/styles/tokens/dark.css +4 -0
  153. package/styles/tokens/semantic.css +56 -28
  154. package/styles/zx.css +3 -0
@@ -101,6 +101,9 @@ export class MenuButton extends Component {
101
101
  content: null
102
102
  });
103
103
  this.#panel = this.#dropdown.getPanel();
104
+ // Escape reaches the menu through the overlay stack, which offers it only to the topmost layer;
105
+ // the menu decides where focus goes. Items are reached with the arrow keys, not Tab.
106
+ this.#dropdown._configure({ onEscape: () => this.#dismiss(), tabReach: false });
104
107
  this.#panel.classList.add('zx-menu-button__menu');
105
108
  this.#panel.setAttribute('role', 'menu');
106
109
  // The menu is named by its trigger, as the APG menu-button pattern has it. An id given here to
@@ -126,7 +129,7 @@ export class MenuButton extends Component {
126
129
  if (this.isOpen()) this.close();
127
130
  else {
128
131
  this.open();
129
- this.#roving.focusFirst();
132
+ this.#focusFirstItem();
130
133
  }
131
134
  });
132
135
  this.listen(this.el, 'keydown', (event) => this.#onTriggerKeydown(event));
@@ -135,6 +138,9 @@ export class MenuButton extends Component {
135
138
  if (item && this.#panel.contains(item)) this.#selectElement(item, event);
136
139
  });
137
140
  this.listen(this.#panel, 'keydown', (event) => this.#onMenuKeydown(event));
141
+ // A choice row runs across, not down: Left and Right move within it, Up and Down leave it.
142
+ // Capture, so the menu's own roving focus never sees the keys this takes.
143
+ this.listen(this.#panel, 'keydown', (event) => this.#onChoiceKeydown(event), { capture: true });
138
144
  }
139
145
 
140
146
  /**
@@ -158,7 +164,7 @@ export class MenuButton extends Component {
158
164
  */
159
165
  setItems(items) {
160
166
  this.#items = Array.isArray(items) ? items.slice() : [];
161
- this.#panel.replaceChildren(...renderMenuEntries(this.#items, 'menu-button'));
167
+ this.#panel.replaceChildren(...renderMenuEntries(this.#items, 'menu-button', { choices: true }));
162
168
  return this;
163
169
  }
164
170
 
@@ -183,6 +189,15 @@ export class MenuButton extends Component {
183
189
  return this;
184
190
  }
185
191
 
192
+ /**
193
+ * Opens the menu when it is closed, and closes it when it is open. Opening this way moves no
194
+ * focus; `focusFirst()` opens with focus on the first item.
195
+ * @returns {this}
196
+ */
197
+ toggle() {
198
+ return this.isOpen() ? this.close() : this.open();
199
+ }
200
+
186
201
  /**
187
202
  * Reports whether the menu is open.
188
203
  * @returns {boolean}
@@ -208,7 +223,7 @@ export class MenuButton extends Component {
208
223
  */
209
224
  focusFirst(options = {}) {
210
225
  this.open(options);
211
- this.#roving.focusFirst();
226
+ this.#focusFirstItem();
212
227
  return this;
213
228
  }
214
229
 
@@ -256,18 +271,11 @@ export class MenuButton extends Component {
256
271
  event.preventDefault();
257
272
  this.open();
258
273
  if (event.key === 'ArrowUp') this.#roving.focusLast();
259
- else this.#roving.focusFirst();
274
+ else this.#focusFirstItem();
260
275
  }
261
276
 
262
277
  /** @param {KeyboardEvent} event @returns {void} */
263
278
  #onMenuKeydown(event) {
264
- if (event.key === 'Escape') {
265
- event.preventDefault();
266
- const target = this.#returnTarget();
267
- this.close();
268
- target?.focus();
269
- return;
270
- }
271
279
  if (event.key === 'Tab') {
272
280
  this.close();
273
281
  return;
@@ -283,10 +291,88 @@ export class MenuButton extends Component {
283
291
  this.#typeahead(event);
284
292
  }
285
293
 
294
+ /**
295
+ * Escape, as the overlay stack routes it here: closes the menu, and returns focus when the menu
296
+ * held it (or it had already fallen to the body).
297
+ * @returns {void}
298
+ */
299
+ #dismiss() {
300
+ const active = document.activeElement;
301
+ const reclaim = !active || active === document.body || this.#panel.contains(active);
302
+ const target = this.#returnTarget();
303
+ this.close();
304
+ if (reclaim) target?.focus();
305
+ }
306
+
307
+ /** Focuses the first item, or, when that is a choice row, its checked option. @returns {void} */
308
+ #focusFirstItem() {
309
+ this.#roving.focusFirst();
310
+ const first = this.#menuItems()[0];
311
+ const checked = first?.hasAttribute('data-menu-option')
312
+ ? first.parentElement?.querySelector('[aria-checked="true"]') : null;
313
+ if (checked instanceof HTMLElement) checked.focus();
314
+ }
315
+
316
+ /** @param {KeyboardEvent} event @returns {void} */
317
+ #onChoiceKeydown(event) {
318
+ const option = /** @type {Element|null} */ (event.target)?.closest?.('[data-menu-option]');
319
+ if (!(option instanceof HTMLElement) || !this.#panel.contains(option)) {
320
+ // Arriving at a choice row from another item lands on its checked option, not its nearest.
321
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
322
+ const items = this.#menuItems();
323
+ const index = items.indexOf(/** @type {HTMLElement} */ (event.target));
324
+ if (index < 0) return;
325
+ const next = items[(index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
326
+ const row = next?.hasAttribute('data-menu-option') ? next.parentElement : null;
327
+ if (!row) return;
328
+ event.preventDefault();
329
+ event.stopPropagation();
330
+ /** @type {HTMLElement} */ (row.querySelector('[aria-checked="true"]') ?? next).focus();
331
+ return;
332
+ }
333
+ const row = /** @type {HTMLElement[]} */ ([...(option.parentElement?.children ?? [])]);
334
+ const items = this.#menuItems();
335
+ let target = null;
336
+ if (event.key === 'ArrowLeft') target = row[(row.indexOf(option) - 1 + row.length) % row.length];
337
+ else if (event.key === 'ArrowRight') target = row[(row.indexOf(option) + 1) % row.length];
338
+ else if (event.key === 'ArrowDown') target = items[(items.indexOf(row.at(-1)) + 1) % items.length];
339
+ else if (event.key === 'ArrowUp') target = items[(items.indexOf(row[0]) - 1 + items.length) % items.length];
340
+ else return;
341
+ event.preventDefault();
342
+ event.stopPropagation();
343
+ // Into another choice row, focus lands on its checked option.
344
+ const checked = target?.closest('[role="group"]')?.querySelector('[data-menu-option][aria-checked="true"]');
345
+ const next = event.key === 'ArrowDown' || event.key === 'ArrowUp' ? /** @type {HTMLElement} */ (checked ?? target) : target;
346
+ next?.focus();
347
+ }
348
+
349
+ /** @param {HTMLElement} element @param {Record<string, any>} entry @param {Event|null} event @returns {void} */
350
+ #selectChoice(element, entry, event) {
351
+ const option = entry.options?.[Number(element.getAttribute('data-menu-option'))];
352
+ if (!option || option.disabled) {
353
+ event?.preventDefault();
354
+ return;
355
+ }
356
+ // A setting changes in place and the menu stays open, so its effect shows.
357
+ for (const sibling of element.parentElement?.children ?? []) {
358
+ sibling.setAttribute('aria-checked', String(sibling === element));
359
+ }
360
+ element.focus();
361
+ entry.onselect?.(option.value, entry, this);
362
+ // A handler that closed the menu leaves focus on a hidden option: it goes where a close sends it.
363
+ if (!this.isOpen() && (element.contains(document.activeElement) || document.activeElement === document.body)) {
364
+ this.#returnTarget()?.focus();
365
+ }
366
+ }
367
+
286
368
  /** @param {Element} element @param {Event|null} [event=null] @returns {void} */
287
369
  #selectElement(element, event = null) {
288
370
  const index = Number(element.getAttribute('data-menu-item'));
289
371
  const item = this.#items[index];
372
+ if (item && typeof item === 'object' && item.type === 'choice') {
373
+ this.#selectChoice(/** @type {HTMLElement} */ (element), item, event);
374
+ return;
375
+ }
290
376
  if (!isMenuItem(item) || item.disabled) {
291
377
  event?.preventDefault();
292
378
  return;
@@ -1,7 +1,8 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h, resolveElement } from '../../core/dom.js';
3
3
  import { icon } from '../../core/icons.js';
4
- import { overlayHost } from '../../core/overlay-host.js';
4
+ import { overlayScope } from '../../core/overlay-host.js';
5
+ import { overlayStack } from '../../core/overlay-stack.js';
5
6
  import { button } from '../button/button.js';
6
7
 
7
8
  /** @typedef {'info'|'success'|'warning'|'error'} MessageKind */
@@ -160,9 +161,6 @@ export class Message extends Component {
160
161
  maxVisible: 5
161
162
  };
162
163
 
163
- /** @type {WeakMap<Element, Message>} Floating region per overlay host. */
164
- static #floating = new WeakMap();
165
-
166
164
  /** @returns {HTMLElement} */
167
165
  render() {
168
166
  const created = !this.el;
@@ -170,8 +168,14 @@ export class Message extends Component {
170
168
  this._createdRoot = created;
171
169
  this._original = created ? null : snapshot(root);
172
170
  this._entries = new Set();
173
- /** @type {Element|null} The overlay host this instance is the floating region of; null when inline. */
171
+ /** @type {Element|null} The theme scope this instance is the floating region of; null when inline. */
174
172
  this._floatingHost = null;
173
+ /** @type {Element|null} Where the floating region was last put: its scope, or the topmost modal. */
174
+ this._placedIn = null;
175
+ /** @type {import('../../core/overlay-stack.js').OverlayHandle|null} The region's toast entry. */
176
+ this._overlay = null;
177
+ /** @type {(() => void)|null} Stops following the overlay stack. */
178
+ this._unfollow = null;
175
179
  this._queue = new MessageQueue(normalizeLimit(this.options.maxVisible), (entry) => {
176
180
  this._activate(entry);
177
181
  });
@@ -276,10 +280,8 @@ export class Message extends Component {
276
280
  if (scope == null) return false;
277
281
  const context = resolveElement(scope);
278
282
  if (!context) return false;
279
- const host = overlayHost(context);
280
- const region = Message.#floating.get(host);
283
+ const region = Message.#regionFor(overlayScope(context));
281
284
  if (!region) return false;
282
- Message.#floating.delete(host);
283
285
  region._disposeFloating();
284
286
  return true;
285
287
  }
@@ -288,7 +290,13 @@ export class Message extends Component {
288
290
  destroy() {
289
291
  if (this._cleaned) return;
290
292
  this._cleaned = true;
293
+ this._unfollow?.();
294
+ this._unfollow = null;
295
+ const overlay = this._overlay;
296
+ this._overlay = null;
297
+ overlay?.release();
291
298
  this._floatingHost = null;
299
+ this._placedIn = null;
292
300
  for (const entry of this._entries) {
293
301
  clearEntryTimers(entry);
294
302
  entry.node.remove();
@@ -525,6 +533,23 @@ export class Message extends Component {
525
533
  Message.#release(this);
526
534
  }
527
535
 
536
+ /**
537
+ * Puts a floating region where it can be seen and used: inside the topmost open modal dialog
538
+ * while one is open — outside it, `showModal()` would leave every toast unclickable — and in its
539
+ * own theme scope otherwise. Re-showing it after a move puts it back on top of the top layer.
540
+ * @returns {void}
541
+ */
542
+ _rehome() {
543
+ const host = this._floatingHost;
544
+ if (!host) return;
545
+ const target = overlayStack.topmostModal() ?? host;
546
+ if (this.el.parentElement === target && this._placedIn === target) return;
547
+ target.append(this.el);
548
+ this._placedIn = target;
549
+ const region = /** @type {HTMLElement} */ (this.el);
550
+ if (typeof region.showPopover === 'function' && !region.matches(':popover-open')) region.showPopover();
551
+ }
552
+
528
553
  /**
529
554
  * Destroys a floating region and removes its element.
530
555
  * @returns {void}
@@ -554,33 +579,48 @@ export class Message extends Component {
554
579
  * @returns {Message}
555
580
  */
556
581
  static #floatingRegion(scope) {
557
- let host = overlayHost(scope ?? null);
582
+ let host = overlayScope(scope ?? null);
558
583
  // A scope that has itself left the document cannot show anything; the toast still must be seen.
559
- if (!host.isConnected) host = overlayHost(null);
560
- const existing = this.#floating.get(host);
561
- if (existing && existing.el.isConnected && existing.el.parentElement === host) return existing;
562
- if (existing) {
563
- this.#floating.delete(host);
564
- existing._disposeFloating();
584
+ if (!host.isConnected) host = overlayScope(null);
585
+ const existing = this.#regionFor(host);
586
+ if (existing && existing.el.isConnected && existing.el.parentElement === existing._placedIn) {
587
+ existing._rehome();
588
+ return existing;
565
589
  }
590
+ existing?._disposeFloating();
566
591
  const region = h('div', {
567
592
  class: 'zx-message-region',
568
593
  popover: 'manual',
569
594
  role: 'status',
570
595
  ariaLive: 'polite'
571
596
  });
572
- host.append(region);
573
597
  const floating = new Message(region);
574
598
  floating._floatingHost = host;
575
- this.#floating.set(host, floating);
576
- if (typeof region.showPopover === 'function') region.showPopover();
599
+ // A toast entry on the overlay stack: the registry of regions, and the reason a press on a
600
+ // toast dismisses no drawer or menu. Following the stack moves the region into a modal dialog
601
+ // when one opens and back out when it closes.
602
+ floating._overlay = overlayStack.push({ kind: 'toast', root: region, owner: floating });
603
+ floating._unfollow = overlayStack.subscribe(() => floating._rehome());
604
+ floating._rehome();
577
605
  return floating;
578
606
  }
579
607
 
608
+ /**
609
+ * The live floating region of a theme scope, from the overlay stack's toast entries.
610
+ * @param {Element} host Theme scope.
611
+ * @returns {Message|null}
612
+ */
613
+ static #regionFor(host) {
614
+ for (const entry of overlayStack.entries()) {
615
+ if (entry.kind === 'toast' && entry.owner instanceof Message && entry.owner._floatingHost === host) {
616
+ return entry.owner;
617
+ }
618
+ }
619
+ return null;
620
+ }
621
+
580
622
  /** @param {Message} region Floating region whose last toast closed. @returns {void} */
581
623
  static #release(region) {
582
- const host = region._floatingHost;
583
- if (host && Message.#floating.get(host) === region) Message.#floating.delete(host);
584
624
  region._disposeFloating();
585
625
  }
586
626
  }
@@ -39,7 +39,7 @@
39
39
  * overlay with nothing on screen saying so.
40
40
  */
41
41
  .zx-modal:focus {
42
- outline: none;
42
+ outline-color: transparent;
43
43
  }
44
44
 
45
45
  .zx-modal:focus-visible {
@@ -1,6 +1,7 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { h } from '../../core/dom.js';
3
- import { connectedOverlayHost, overlayHost } from '../../core/overlay-host.js';
3
+ import { connectedOverlayHost, overlayScope } from '../../core/overlay-host.js';
4
+ import { pushOverlay } from '../../core/overlay-stack.js';
4
5
 
5
6
  /**
6
7
  * Controls initial focus may be placed on: what a reader reaches with Tab, rather than everything
@@ -62,6 +63,8 @@ export class Modal extends Component {
62
63
  #nativeClosingPresentations = [];
63
64
  /** @type {Element|null} Element that had focus when the overlay was opened. */
64
65
  #opener = null;
66
+ /** @type {import('../../core/overlay-stack.js').OverlayHandle|null} Entry on the overlay stack while shown. */
67
+ #overlay = null;
65
68
 
66
69
  /**
67
70
  * Creates a modal in the configured theme scope, or at document level when none is present.
@@ -103,7 +106,9 @@ export class Modal extends Component {
103
106
  * @returns {Element}
104
107
  */
105
108
  mountTarget() {
106
- return overlayHost(this.options.scope);
109
+ // The theme scope, never an open dialog: a dialog opened from inside another stacks above it in
110
+ // the top layer wherever it lives, and nesting it would tie its rendering to the outer one.
111
+ return overlayScope(this.options.scope);
107
112
  }
108
113
 
109
114
  /**
@@ -159,6 +164,61 @@ export class Modal extends Component {
159
164
  else if (this.el.getAttribute('aria-modal') === 'true') this.el.removeAttribute('aria-modal');
160
165
  }
161
166
 
167
+ /**
168
+ * Describes the current presentation for the overlay stack, or returns null to stay off it.
169
+ *
170
+ * The kind is read from the platform like `aria-modal` is: a `showModal()` dialog is `modal`,
171
+ * and its Escape and backdrop stay the browser's; anything else is `non-modal`, and a subclass
172
+ * presenting that way adds the Escape and outside-press handling it wants.
173
+ * @returns {import('../../core/overlay-stack.js').OverlayEntryOptions|null}
174
+ * @protected
175
+ */
176
+ _overlayEntry() {
177
+ return {
178
+ kind: this.el.matches(':modal') ? 'modal' : 'non-modal',
179
+ root: this.el,
180
+ owner: this,
181
+ anchor: this.#opener
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Re-registers the current presentation on the overlay stack: called after every presentation,
187
+ * including a subclass's change of address that bypasses `open()`.
188
+ * @returns {void}
189
+ * @protected
190
+ */
191
+ _syncOverlay() {
192
+ this._releaseOverlay();
193
+ if (!this._isShown()) return;
194
+ const entry = this._overlayEntry();
195
+ if (!entry) return;
196
+ this.#overlay = pushOverlay(entry);
197
+ this.el.dataset.zxOverlayOrder = String(this.#overlay.order);
198
+ }
199
+
200
+ /**
201
+ * Takes the overlay off the stack. Safe to call repeatedly.
202
+ * @returns {void}
203
+ * @protected
204
+ */
205
+ _releaseOverlay() {
206
+ const overlay = this.#overlay;
207
+ this.#overlay = null;
208
+ delete (/** @type {HTMLElement} */ (this.el)).dataset.zxOverlayOrder;
209
+ overlay?.release();
210
+ }
211
+
212
+ /**
213
+ * Whether a node is inside this overlay or anything opened from it, as the overlay stack sees it.
214
+ * @param {Node|null|undefined} node Candidate.
215
+ * @returns {boolean}
216
+ * @protected
217
+ */
218
+ _overlayContains(node) {
219
+ return this.#overlay ? this.#overlay.contains(node) : this.el.contains(node ?? null);
220
+ }
221
+
162
222
  /**
163
223
  * Hides the current presentation and records which generation its queued close event belongs to.
164
224
  * @param {string} [result=''] Native string return value.
@@ -202,7 +262,7 @@ export class Modal extends Component {
202
262
  return false;
203
263
  }
204
264
  this.el.dataset.state = 'closed';
205
- delete this.el.dataset.zxOverlayOrder;
265
+ this._releaseOverlay();
206
266
  /*
207
267
  * Only the flag this class raised. A subclass presenting non-modally writes
208
268
  * `aria-modal="false"` and owns that value across its own presentations.
@@ -300,16 +360,15 @@ export class Modal extends Component {
300
360
  this.#opener = document.activeElement instanceof Element ? document.activeElement : null;
301
361
  const host = this.mountTarget();
302
362
  if (this.el.parentElement !== host) host.append(this.el);
303
- this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
304
363
  this.el.returnValue = '';
305
364
  try {
306
365
  this._present();
307
366
  } catch (error) {
308
- delete this.el.dataset.zxOverlayOrder;
309
367
  // Nothing was presented, so nothing will close; a capture left behind would outlive its open.
310
368
  this.#opener = null;
311
369
  throw error;
312
370
  }
371
+ this._syncOverlay();
313
372
  this.el.dataset.state = 'open';
314
373
  this.emit('open');
315
374
  // Deferred by one microtask: the platform's own focusing step runs as part of presenting, and
@@ -362,6 +421,8 @@ export class Modal extends Component {
362
421
  if (this.#destroying) return;
363
422
  this.#destroying = true;
364
423
  if (this.isOpen()) this._dismiss();
424
+ // The queued close that would settle it never arrives once listeners are aborted.
425
+ this._releaseOverlay();
365
426
  super.destroy();
366
427
  }
367
428
 
@@ -458,11 +519,3 @@ function isBackdropClick(dialog, event) {
458
519
  return event.clientX < rect.left || event.clientX > rect.right ||
459
520
  event.clientY < rect.top || event.clientY > rect.bottom;
460
521
  }
461
-
462
- /** @returns {number} */
463
- function nextOverlayOrder() {
464
- const orders = Array.from(document.querySelectorAll('[data-zx-overlay-order]'), (element) =>
465
- Number(element.getAttribute('data-zx-overlay-order')) || 0
466
- );
467
- return Math.max(0, ...orders) + 1;
468
- }