@zeyos/zx 4.4.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 (252) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/README.md +44 -11
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +31 -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 +293 -17
  7. package/dist/types/components/avatar/avatar.d.ts +46 -7
  8. package/dist/types/components/badge/badge.d.ts +21 -1
  9. package/dist/types/components/button/button.d.ts +15 -0
  10. package/dist/types/components/calendar/calendar.d.ts +67 -5
  11. package/dist/types/components/card-view/card-view.d.ts +7 -4
  12. package/dist/types/components/card-view/record-card.d.ts +11 -0
  13. package/dist/types/components/chart/chart.d.ts +10 -0
  14. package/dist/types/components/checklist/checklist.d.ts +9 -0
  15. package/dist/types/components/code-editor/code-editor.d.ts +6 -17
  16. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  17. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  18. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  19. package/dist/types/components/datebox/datebox.d.ts +7 -2
  20. package/dist/types/components/dialog/dialog.d.ts +46 -6
  21. package/dist/types/components/dropdown/dropdown.d.ts +24 -9
  22. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  23. package/dist/types/components/field/field.d.ts +54 -2
  24. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  25. package/dist/types/components/fieldset/fieldset.d.ts +26 -1
  26. package/dist/types/components/file-item/file-item.d.ts +34 -6
  27. package/dist/types/components/file-list/file-list.d.ts +35 -3
  28. package/dist/types/components/filter/filter.d.ts +127 -3
  29. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  30. package/dist/types/components/form/form.d.ts +12 -1
  31. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  32. package/dist/types/components/launcher/launcher.d.ts +268 -15
  33. package/dist/types/components/list-toolbar/list-toolbar.d.ts +16 -7
  34. package/dist/types/components/menu-button/menu-button.d.ts +39 -5
  35. package/dist/types/components/message/message.d.ts +132 -17
  36. package/dist/types/components/modal/modal.d.ts +43 -2
  37. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  38. package/dist/types/components/pagination/pagination.d.ts +17 -3
  39. package/dist/types/components/select/color.d.ts +12 -0
  40. package/dist/types/components/select/glyphs.d.ts +124 -0
  41. package/dist/types/components/select/select.d.ts +17 -3
  42. package/dist/types/components/sheet/sheet.d.ts +15 -0
  43. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  44. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  45. package/dist/types/components/table/table.d.ts +311 -18
  46. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  47. package/dist/types/components/tree/tree.d.ts +13 -0
  48. package/dist/types/components/view/record-view.d.ts +59 -2
  49. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  50. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  51. package/dist/types/core/breakpoint.d.ts +1 -1
  52. package/dist/types/core/component.d.ts +12 -2
  53. package/dist/types/core/i18n.d.ts +27 -3
  54. package/dist/types/core/icons.d.ts +23 -25
  55. package/dist/types/core/keyboard.d.ts +98 -1
  56. package/dist/types/core/messages.d.ts +382 -0
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +42 -1
  59. package/dist/types/core/overlay-stack.d.ts +260 -0
  60. package/dist/types/index.d.ts +10 -5
  61. package/dist/types/internal/app-rail.d.ts +38 -0
  62. package/dist/types/internal/menu-items.d.ts +71 -3
  63. package/dist/types/zeyos/connect.d.ts +6 -0
  64. package/dist/types/zeyos/form.d.ts +50 -9
  65. package/dist/types/zeyos/index.d.ts +2 -1
  66. package/dist/types/zeyos/launcher.d.ts +9 -0
  67. package/dist/types/zeyos/module-bar.d.ts +176 -0
  68. package/dist/types/zeyos/query.d.ts +110 -5
  69. package/dist/types/zeyos/schema.d.ts +24 -1
  70. package/dist/types/zeyos/table.d.ts +345 -22
  71. package/dist/types/zeyos/view.d.ts +26 -2
  72. package/dist/zx-zeyos.esm.js +6178 -1008
  73. package/dist/zx-zeyos.esm.min.js +53 -5
  74. package/dist/zx.css +2861 -827
  75. package/dist/zx.esm.js +8909 -1480
  76. package/dist/zx.esm.min.js +55 -7
  77. package/dist/zx.global.js +8911 -1482
  78. package/dist/zx.global.min.js +55 -7
  79. package/dist/zx.min.css +1 -1
  80. package/docs/api.json +1079 -117
  81. package/docs/llms.md +956 -154
  82. package/docs/llms.txt +9 -5
  83. package/package.json +1 -1
  84. package/src/components/account-menu/account-menu.css +35 -10
  85. package/src/components/account-menu/account-menu.js +12 -0
  86. package/src/components/activity-item/activity-item.css +1 -1
  87. package/src/components/activity-item/activity-item.js +3 -0
  88. package/src/components/activity-list/activity-list.css +38 -3
  89. package/src/components/activity-list/activity-list.js +61 -1
  90. package/src/components/app-icon/app-icon.css +14 -3
  91. package/src/components/app-icon/app-icon.js +11 -1
  92. package/src/components/app-rail/app-rail.css +59 -79
  93. package/src/components/app-sidebar/app-sidebar.css +304 -168
  94. package/src/components/app-sidebar/app-sidebar.js +667 -46
  95. package/src/components/aurora/aurora.css +5 -3
  96. package/src/components/avatar/avatar.css +32 -7
  97. package/src/components/avatar/avatar.js +72 -8
  98. package/src/components/badge/badge.css +54 -2
  99. package/src/components/badge/badge.js +29 -2
  100. package/src/components/breadcrumb/breadcrumb.css +1 -1
  101. package/src/components/button/button.css +67 -9
  102. package/src/components/button/button.js +38 -0
  103. package/src/components/calendar/calendar.css +47 -47
  104. package/src/components/calendar/calendar.js +26 -52
  105. package/src/components/card/card.css +4 -4
  106. package/src/components/card-view/card-view.css +14 -12
  107. package/src/components/card-view/card-view.js +13 -9
  108. package/src/components/card-view/record-card.js +14 -3
  109. package/src/components/chart/chart.css +4 -1
  110. package/src/components/chart/chart.js +12 -0
  111. package/src/components/check-button/check-button.css +10 -2
  112. package/src/components/checklist/checklist.css +9 -2
  113. package/src/components/checklist/checklist.js +11 -5
  114. package/src/components/checklist/field-adapter.js +3 -1
  115. package/src/components/code-editor/code-editor.css +7 -6
  116. package/src/components/code-editor/code-editor.js +19 -1
  117. package/src/components/code-editor/field-adapter.js +9 -2
  118. package/src/components/context-menu/context-menu.css +9 -4
  119. package/src/components/context-menu/context-menu.js +8 -2
  120. package/src/components/copy/copy-button.css +6 -0
  121. package/src/components/copy/copy-input.css +9 -4
  122. package/src/components/data-filter/data-filter.css +5 -4
  123. package/src/components/data-filter/data-filter.js +2 -2
  124. package/src/components/date-picker/date-picker.css +6 -6
  125. package/src/components/date-picker/date-range-picker.css +3 -3
  126. package/src/components/date-picker/time-picker.css +3 -3
  127. package/src/components/datebox/date-range-box.css +79 -2
  128. package/src/components/datebox/date-range-box.js +13 -2
  129. package/src/components/datebox/datebox.css +79 -2
  130. package/src/components/datebox/datebox.js +13 -2
  131. package/src/components/dialog/dialog.css +26 -0
  132. package/src/components/dialog/dialog.js +99 -17
  133. package/src/components/dock/dock.css +5 -4
  134. package/src/components/dropdown/dropdown.css +1 -1
  135. package/src/components/dropdown/dropdown.js +159 -72
  136. package/src/components/empty-state/empty-state.css +2 -2
  137. package/src/components/entity-ref/entity-ref.css +1 -1
  138. package/src/components/entity-ref/entity-ref.js +1 -1
  139. package/src/components/field/field.css +28 -9
  140. package/src/components/field/field.js +101 -29
  141. package/src/components/field-upload/field-upload.css +1 -1
  142. package/src/components/fieldset/external-adapter.js +363 -0
  143. package/src/components/fieldset/fieldset.css +5 -3
  144. package/src/components/fieldset/fieldset.js +38 -64
  145. package/src/components/file-item/file-item.js +144 -10
  146. package/src/components/file-list/file-list.js +144 -21
  147. package/src/components/filter/filter.css +86 -2
  148. package/src/components/filter/filter.js +307 -11
  149. package/src/components/filter-panel/filter-panel.css +1 -1
  150. package/src/components/filter-panel/filter-panel.js +149 -32
  151. package/src/components/finder/finder.css +16 -14
  152. package/src/components/form/form.css +5 -4
  153. package/src/components/form/form.js +11 -2
  154. package/src/components/grid/grid.css +1 -1
  155. package/src/components/groupbox/groupbox.css +1 -1
  156. package/src/components/kanban-view/kanban-card.js +11 -3
  157. package/src/components/kanban-view/kanban-view.css +42 -18
  158. package/src/components/kanban-view/kanban-view.js +9 -6
  159. package/src/components/launcher/launcher.css +108 -26
  160. package/src/components/launcher/launcher.js +530 -42
  161. package/src/components/list-toolbar/list-toolbar.css +13 -8
  162. package/src/components/list-toolbar/list-toolbar.js +40 -14
  163. package/src/components/master-panel/master-panel.css +2 -0
  164. package/src/components/master-panel/master-panel.js +0 -1
  165. package/src/components/menu-button/menu-button.css +72 -4
  166. package/src/components/menu-button/menu-button.js +160 -18
  167. package/src/components/message/message.css +33 -3
  168. package/src/components/message/message.js +264 -22
  169. package/src/components/modal/modal.css +1 -1
  170. package/src/components/modal/modal.js +83 -15
  171. package/src/components/module-bar/module-bar.css +486 -0
  172. package/src/components/module-bar/module-bar.js +1074 -0
  173. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  174. package/src/components/navigation-bar/navigation-bar.css +18 -9
  175. package/src/components/number-field/field-adapter.js +3 -1
  176. package/src/components/number-field/number-field.css +70 -0
  177. package/src/components/number-field/number-field.js +2 -0
  178. package/src/components/pagination/pagination.css +13 -11
  179. package/src/components/pagination/pagination.js +20 -4
  180. package/src/components/panel/panel.css +4 -3
  181. package/src/components/questionnaire/questionnaire.css +4 -4
  182. package/src/components/rating/field-adapter.js +2 -1
  183. package/src/components/rating/rating.css +8 -1
  184. package/src/components/search/search.css +4 -1
  185. package/src/components/select/color.js +22 -0
  186. package/src/components/select/field-adapter.js +2 -0
  187. package/src/components/select/glyphs.js +165 -0
  188. package/src/components/select/select.css +138 -45
  189. package/src/components/select/select.js +92 -47
  190. package/src/components/sheet/sheet.css +12 -9
  191. package/src/components/sheet/sheet.js +74 -32
  192. package/src/components/slider/field-adapter.js +6 -1
  193. package/src/components/slider/slider.css +18 -4
  194. package/src/components/slider/slider.js +1 -1
  195. package/src/components/sort-control/sort-control.css +4 -1
  196. package/src/components/split-view/split-view.css +1 -1
  197. package/src/components/stat-tile/stat-tile.css +42 -47
  198. package/src/components/stat-tile/stat-tile.js +17 -6
  199. package/src/components/stepper/stepper.css +1 -1
  200. package/src/components/tabbox/tabbox.css +41 -32
  201. package/src/components/tabbox/tabbox.js +5 -1
  202. package/src/components/table/table.css +187 -37
  203. package/src/components/table/table.js +718 -73
  204. package/src/components/tag-picker/field-adapter.js +2 -1
  205. package/src/components/tag-picker/tag-picker.css +83 -5
  206. package/src/components/tag-picker/tag-picker.js +25 -8
  207. package/src/components/timebox/timebox.css +22 -2
  208. package/src/components/toggle/toggle.css +12 -1
  209. package/src/components/toolbar/toolbar.js +2 -2
  210. package/src/components/tooltip/tooltip.css +4 -0
  211. package/src/components/tooltip/tooltip.js +44 -19
  212. package/src/components/tree/tree.css +26 -17
  213. package/src/components/tree/tree.js +31 -0
  214. package/src/components/value-list/value-list.css +3 -3
  215. package/src/components/view/record-view.css +1 -1
  216. package/src/components/view/record-view.js +178 -18
  217. package/src/components/view-settings/view-settings.css +285 -0
  218. package/src/components/view-settings/view-settings.js +1830 -0
  219. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  220. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  221. package/src/core/breakpoint.js +1 -1
  222. package/src/core/component.js +88 -17
  223. package/src/core/fontawesome.js +5 -1
  224. package/src/core/i18n.js +47 -4
  225. package/src/core/icons.js +204 -61
  226. package/src/core/keyboard.js +206 -11
  227. package/src/core/messages.js +427 -0
  228. package/src/core/motion.js +147 -0
  229. package/src/core/overlay-host.js +77 -3
  230. package/src/core/overlay-stack.js +487 -0
  231. package/src/core/position.js +22 -3
  232. package/src/index.js +10 -5
  233. package/src/internal/app-rail.js +135 -55
  234. package/src/internal/menu-items.js +61 -3
  235. package/src/zeyos/connect.js +6 -2
  236. package/src/zeyos/form.js +69 -27
  237. package/src/zeyos/index.js +4 -1
  238. package/src/zeyos/launcher.js +8 -3
  239. package/src/zeyos/module-bar.js +181 -0
  240. package/src/zeyos/query.js +197 -13
  241. package/src/zeyos/schema.js +144 -27
  242. package/src/zeyos/table.js +287 -84
  243. package/src/zeyos/view.js +49 -93
  244. package/styles/base.css +72 -1
  245. package/styles/icons.css +11 -1
  246. package/styles/print.css +1 -1
  247. package/styles/tokens/dark.css +117 -37
  248. package/styles/tokens/global.css +121 -27
  249. package/styles/tokens/semantic.css +140 -43
  250. package/styles/tokens/themes.css +7 -6
  251. package/styles/zx.css +3 -1
  252. package/styles/tokens/density.css +0 -9
@@ -1,15 +1,33 @@
1
1
  import { Component } from '../../core/component.js';
2
- import { h } from '../../core/dom.js';
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';
6
+ import { button } from '../button/button.js';
5
7
 
6
8
  /** @typedef {'info'|'success'|'warning'|'error'} MessageKind */
7
9
 
10
+ /**
11
+ * The one command a message may carry, drawn as a small button beside its text.
12
+ * @typedef {Object} MessageAction
13
+ * @property {string} label Button text: a verb naming what happens ("Undo", "Approve", "Retry").
14
+ * @property {(event: MouseEvent) => void} [onclick] Runs when the button is activated, by pointer
15
+ * or keyboard; the message closes right after it.
16
+ * @property {'default'|'primary'|'danger'|'ghost'} [kind='default'] Button kind; the default is the
17
+ * secondary button.
18
+ */
19
+
8
20
  /**
9
21
  * @typedef {Object} MessageShowOptions
10
22
  * @property {MessageKind|'danger'} [kind='info'] Message intent (`danger` aliases `error`).
11
- * @property {number} [timeout=4000] Auto-dismiss delay in milliseconds; zero persists.
23
+ * @property {number} [timeout=4000] Auto-dismiss delay in milliseconds; zero persists. A message
24
+ * with an `action` stays at least 8000ms.
12
25
  * @property {boolean} [closable=true] Whether to render a close button.
26
+ * @property {MessageAction|null} [action=null] A command button inside the message. The message
27
+ * never times out while the pointer is over it or focus is inside it.
28
+ * @property {Node|{toElement: () => Node|null}|null} [leading=null] Visual shown in place of the
29
+ * kind icon, such as an Avatar's element. It is decorative — the message text must say what it
30
+ * shows.
13
31
  * @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns a floating toast.
14
32
  */
15
33
 
@@ -21,7 +39,15 @@ import { overlayHost } from '../../core/overlay-host.js';
21
39
  * @property {number} [maxVisible=5] Maximum number of visible messages.
22
40
  */
23
41
 
24
- /** @typedef {{close: () => void}} MessageHandle */
42
+ /**
43
+ * Returned by every `show()`: closes that message early. Closing one that already closed, or one
44
+ * still waiting in the queue, is safe.
45
+ * @typedef {Object} MessageHandle
46
+ * @property {() => void} close Closes the message.
47
+ */
48
+
49
+ /** The shortest time a message that carries an action stays, in milliseconds. */
50
+ export const MESSAGE_ACTION_MIN_TIMEOUT = 8000;
25
51
 
26
52
  /**
27
53
  * @typedef {Object} ProgressHandle
@@ -135,9 +161,6 @@ export class Message extends Component {
135
161
  maxVisible: 5
136
162
  };
137
163
 
138
- /** @type {WeakMap<Element, Message>} */
139
- static #floating = new WeakMap();
140
-
141
164
  /** @returns {HTMLElement} */
142
165
  render() {
143
166
  const created = !this.el;
@@ -145,6 +168,14 @@ export class Message extends Component {
145
168
  this._createdRoot = created;
146
169
  this._original = created ? null : snapshot(root);
147
170
  this._entries = new Set();
171
+ /** @type {Element|null} The theme scope this instance is the floating region of; null when inline. */
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;
148
179
  this._queue = new MessageQueue(normalizeLimit(this.options.maxVisible), (entry) => {
149
180
  this._activate(entry);
150
181
  });
@@ -161,10 +192,13 @@ export class Message extends Component {
161
192
  * @returns {MessageHandle}
162
193
  */
163
194
  show(msg, options = {}) {
195
+ const action = normalizeMessageAction(options.action);
164
196
  const settings = {
165
197
  kind: normalizeKind(options.kind ?? this.options.kind),
166
- timeout: normalizeTimeout(options.timeout ?? this.options.timeout),
167
- closable: options.closable ?? Boolean(this.options.closable)
198
+ timeout: messageTimeout(options.timeout ?? this.options.timeout, action !== null),
199
+ closable: options.closable ?? Boolean(this.options.closable),
200
+ action,
201
+ leading: resolveLeading(options.leading)
168
202
  };
169
203
  const entry = this._createEntry(msg, settings);
170
204
  this._entries.add(entry);
@@ -232,10 +266,37 @@ export class Message extends Component {
232
266
  return this.#floatingRegion(options.scope)._progress(text);
233
267
  }
234
268
 
269
+ /**
270
+ * Removes a floating region at once, toasts and all, without waiting for them to time out.
271
+ *
272
+ * A region already removes itself when its last toast closes, and one whose host has left the
273
+ * document is replaced on the next toast, so this is only for a host being torn down while
274
+ * toasts are still showing — an application view unmounting its themed shell, say.
275
+ * @param {Element|string} scope Element or selector whose nearest Zx theme scope owns the region,
276
+ * as passed to `show()`.
277
+ * @returns {boolean} Whether there was a region to remove.
278
+ */
279
+ static disposeRegion(scope) {
280
+ if (scope == null) return false;
281
+ const context = resolveElement(scope);
282
+ if (!context) return false;
283
+ const region = Message.#regionFor(overlayScope(context));
284
+ if (!region) return false;
285
+ region._disposeFloating();
286
+ return true;
287
+ }
288
+
235
289
  /** @returns {void} */
236
290
  destroy() {
237
291
  if (this._cleaned) return;
238
292
  this._cleaned = true;
293
+ this._unfollow?.();
294
+ this._unfollow = null;
295
+ const overlay = this._overlay;
296
+ this._overlay = null;
297
+ overlay?.release();
298
+ this._floatingHost = null;
299
+ this._placedIn = null;
239
300
  for (const entry of this._entries) {
240
301
  clearEntryTimers(entry);
241
302
  entry.node.remove();
@@ -288,13 +349,13 @@ export class Message extends Component {
288
349
 
289
350
  /**
290
351
  * @param {string|Node} msg
291
- * @param {{kind: MessageKind, timeout: number, closable: boolean}} settings
352
+ * @param {{kind: MessageKind, timeout: number, closable: boolean, action?: MessageAction|null, leading?: Node|null}} settings
292
353
  * @param {{pct: number, text: string}|null} [progress=null]
293
354
  * @returns {any}
294
355
  */
295
356
  _createEntry(msg, settings, progress = null) {
296
357
  const iconHost = h('span', { class: 'zx-message__icon', ariaHidden: 'true' },
297
- icon(settings.kind, { size: 18 })
358
+ settings.leading ?? icon(settings.kind, { size: 18 })
298
359
  );
299
360
  const content = h('div', { class: 'zx-message__content' });
300
361
  if (isNode(msg)) content.append(msg);
@@ -315,9 +376,13 @@ export class Message extends Component {
315
376
  timer: null,
316
377
  exitTimer: null,
317
378
  closed: false,
379
+ hovered: false,
380
+ focused: false,
381
+ returnFocus: null,
318
382
  progressBar: null,
319
383
  progressFill: null
320
384
  };
385
+ if (settings.leading) node.dataset.leading = '';
321
386
 
322
387
  if (progress) {
323
388
  node.dataset.progress = '';
@@ -336,6 +401,21 @@ export class Message extends Component {
336
401
  syncProgress(entry, progress);
337
402
  }
338
403
 
404
+ if (settings.action) {
405
+ const { label, kind, onclick } = settings.action;
406
+ const control = button({ label, kind, size: 'sm' });
407
+ control.classList.add('zx-message__action');
408
+ node.append(control);
409
+ this.listen(control, 'click', (event) => {
410
+ if (entry.closed) return;
411
+ try {
412
+ onclick?.(/** @type {MouseEvent} */ (event));
413
+ } finally {
414
+ this._close(entry);
415
+ }
416
+ });
417
+ }
418
+
339
419
  if (settings.closable) {
340
420
  const close = h('button', {
341
421
  class: 'zx-icon-btn zx-message__close',
@@ -346,8 +426,31 @@ export class Message extends Component {
346
426
  node.append(close);
347
427
  this.listen(close, 'click', () => this._close(entry));
348
428
  }
349
- this.listen(node, 'mouseenter', () => this._pause(entry));
350
- this.listen(node, 'mouseleave', () => this._resume(entry));
429
+ // Hover and focus each hold the timer (WCAG 2.2.1): a reader moving to the action or the close
430
+ // button must not have the message vanish under them, and it counts down again only once
431
+ // neither holds it.
432
+ this.listen(node, 'mouseenter', () => {
433
+ entry.hovered = true;
434
+ this._pause(entry);
435
+ });
436
+ this.listen(node, 'mouseleave', () => {
437
+ entry.hovered = false;
438
+ this._resume(entry);
439
+ });
440
+ this.listen(node, 'focusin', (event) => {
441
+ if (!entry.focused) {
442
+ const from = /** @type {FocusEvent} */ (event).relatedTarget;
443
+ entry.returnFocus = isNode(from) && !node.contains(/** @type {Node} */ (from)) ? from : null;
444
+ }
445
+ entry.focused = true;
446
+ this._pause(entry);
447
+ });
448
+ this.listen(node, 'focusout', (event) => {
449
+ const to = /** @type {FocusEvent} */ (event).relatedTarget;
450
+ if (isNode(to) && node.contains(/** @type {Node} */ (to))) return;
451
+ entry.focused = false;
452
+ this._resume(entry);
453
+ });
351
454
  return entry;
352
455
  }
353
456
 
@@ -361,7 +464,7 @@ export class Message extends Component {
361
464
 
362
465
  /** @param {any} entry @returns {void} */
363
466
  _schedule(entry) {
364
- if (entry.closed || entry.remaining <= 0) return;
467
+ if (entry.closed || entry.remaining <= 0 || entry.hovered || entry.focused) return;
365
468
  entry.startedAt = Date.now();
366
469
  entry.timer = setTimeout(() => this._close(entry), entry.remaining);
367
470
  }
@@ -376,7 +479,7 @@ export class Message extends Component {
376
479
 
377
480
  /** @param {any} entry @returns {void} */
378
481
  _resume(entry) {
379
- if (entry.closed || entry.timer !== null) return;
482
+ if (entry.closed || entry.timer !== null || entry.hovered || entry.focused) return;
380
483
  if (entry.remaining <= 0) {
381
484
  this._close(entry);
382
485
  return;
@@ -389,10 +492,12 @@ export class Message extends Component {
389
492
  if (entry.closed) return;
390
493
  entry.closed = true;
391
494
  clearEntryTimers(entry);
495
+ returnFocus(entry);
392
496
  if (!this._queue.isActive(entry)) {
393
497
  this._queue.remove(entry);
394
498
  entry.node.remove();
395
499
  this._entries.delete(entry);
500
+ this._releaseIfEmpty();
396
501
  return;
397
502
  }
398
503
 
@@ -415,6 +520,44 @@ export class Message extends Component {
415
520
  entry.node.remove();
416
521
  this._entries.delete(entry);
417
522
  this._queue.remove(entry);
523
+ this._releaseIfEmpty();
524
+ }
525
+
526
+ /**
527
+ * Takes a floating region out of the document once its last toast has gone, so an emptied host
528
+ * is not left holding a region nobody uses. Inline areas stay: their element is the caller's.
529
+ * @returns {void}
530
+ */
531
+ _releaseIfEmpty() {
532
+ if (!this._floatingHost || this._entries.size > 0) return;
533
+ Message.#release(this);
534
+ }
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
+
553
+ /**
554
+ * Destroys a floating region and removes its element.
555
+ * @returns {void}
556
+ */
557
+ _disposeFloating() {
558
+ const region = this.el;
559
+ this.destroy();
560
+ region.remove();
418
561
  }
419
562
 
420
563
  /**
@@ -428,23 +571,58 @@ export class Message extends Component {
428
571
  return message === key ? fallback : message;
429
572
  }
430
573
 
431
- /** @param {Element|string|null|undefined} scope @returns {Message} */
574
+ /**
575
+ * Finds or creates the floating region for a scope. A cached region is reused only while it is
576
+ * still where it was put: a host emptied by `replaceChildren()` would otherwise keep receiving
577
+ * toasts in a node nobody can see.
578
+ * @param {Element|string|null|undefined} scope
579
+ * @returns {Message}
580
+ */
432
581
  static #floatingRegion(scope) {
433
- const host = overlayHost(scope ?? null);
434
- const existing = this.#floating.get(host);
435
- if (existing) return existing;
582
+ let host = overlayScope(scope ?? null);
583
+ // A scope that has itself left the document cannot show anything; the toast still must be seen.
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;
589
+ }
590
+ existing?._disposeFloating();
436
591
  const region = h('div', {
437
592
  class: 'zx-message-region',
438
593
  popover: 'manual',
439
594
  role: 'status',
440
595
  ariaLive: 'polite'
441
596
  });
442
- host.append(region);
443
597
  const floating = new Message(region);
444
- this.#floating.set(host, floating);
445
- if (typeof region.showPopover === 'function') region.showPopover();
598
+ floating._floatingHost = host;
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();
446
605
  return floating;
447
606
  }
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
+
622
+ /** @param {Message} region Floating region whose last toast closed. @returns {void} */
623
+ static #release(region) {
624
+ region._disposeFloating();
625
+ }
448
626
  }
449
627
 
450
628
  /** @param {unknown} value @returns {MessageKind} */
@@ -460,6 +638,70 @@ function normalizeTimeout(value) {
460
638
  return Number.isFinite(timeout) ? Math.max(0, timeout) : 4000;
461
639
  }
462
640
 
641
+ /**
642
+ * Settles how long a message stays. A message that carries an action stays at least
643
+ * `MESSAGE_ACTION_MIN_TIMEOUT`, so there is time to reach it; zero still means it stays until
644
+ * closed, and anything that is not a finite number falls back to the 4000ms default.
645
+ * @param {unknown} timeout Requested auto-dismiss delay in milliseconds.
646
+ * @param {boolean} [hasAction=false] Whether the message carries an action.
647
+ * @returns {number}
648
+ */
649
+ export function messageTimeout(timeout, hasAction = false) {
650
+ const settled = normalizeTimeout(timeout);
651
+ return hasAction && settled > 0 ? Math.max(settled, MESSAGE_ACTION_MIN_TIMEOUT) : settled;
652
+ }
653
+
654
+ /**
655
+ * Copies a message action into the shape the message renders, or returns null when there is
656
+ * nothing to render: no object, or no label. An unknown `kind` reads as the default, secondary
657
+ * button, and a missing `onclick` leaves a button that only closes the message.
658
+ * @param {unknown} value Action descriptor.
659
+ * @returns {{label: string, kind: 'default'|'primary'|'danger'|'ghost', onclick: ((event: MouseEvent) => void)|null}|null}
660
+ */
661
+ export function normalizeMessageAction(value) {
662
+ if (!value || typeof value !== 'object') return null;
663
+ const action = /** @type {Record<string, unknown>} */ (value);
664
+ const label = action.label == null ? '' : String(action.label).trim();
665
+ if (!label) return null;
666
+ const kind = ['primary', 'danger', 'ghost'].includes(String(action.kind))
667
+ ? /** @type {'primary'|'danger'|'ghost'} */ (action.kind) : 'default';
668
+ return {
669
+ label,
670
+ kind,
671
+ onclick: typeof action.onclick === 'function'
672
+ ? /** @type {(event: MouseEvent) => void} */ (action.onclick) : null
673
+ };
674
+ }
675
+
676
+ /**
677
+ * Accepts a node, or a component such as an Avatar, for the leading slot.
678
+ * @param {any} value Leading content.
679
+ * @returns {Node|null}
680
+ */
681
+ function resolveLeading(value) {
682
+ if (isNode(value)) return /** @type {Node} */ (value);
683
+ if (value && typeof value === 'object' && typeof value.toElement === 'function') {
684
+ const element = value.toElement();
685
+ return isNode(element) ? element : null;
686
+ }
687
+ return null;
688
+ }
689
+
690
+ /**
691
+ * Hands focus back to where it came from when a message closes with focus inside it, so a keyboard
692
+ * reader is not dropped on the page body with the element they were on gone.
693
+ * @param {any} entry Message entry.
694
+ * @returns {void}
695
+ */
696
+ function returnFocus(entry) {
697
+ const root = entry.node.getRootNode?.();
698
+ const active = root?.activeElement ?? globalThis.document?.activeElement;
699
+ if (!active || !entry.node.contains(active)) return;
700
+ const target = entry.returnFocus;
701
+ entry.returnFocus = null;
702
+ if (target?.isConnected && typeof target.focus === 'function') target.focus({ preventScroll: true });
703
+ }
704
+
463
705
  /** @param {unknown} value @returns {number} */
464
706
  function normalizeLimit(value) {
465
707
  const limit = Number(value);
@@ -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 { 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
@@ -18,7 +19,7 @@ const FOCUSABLE_SELECTOR = [
18
19
  * @property {boolean} [closable=true] Whether Escape may close the modal.
19
20
  * @property {boolean} [lightDismiss=false] Whether a backdrop click closes the modal.
20
21
  * @property {boolean} [destroyOnClose=false] Whether to destroy the modal after it closes.
21
- * @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns the overlay; defaults to the opener.
22
+ * @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns the overlay; defaults to the opener. Resolved on every open, so a scope connected after construction still owns it.
22
23
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open event listener.
23
24
  * @property {(event: CustomEvent<{result: unknown}>) => void} [onclose] Close event listener.
24
25
  * @property {(event: CustomEvent<Record<string, never>>) => void} [oncancel] Cancel event listener.
@@ -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.
@@ -266,10 +326,25 @@ export class Modal extends Component {
266
326
  class: 'zx-modal'
267
327
  }, h('div', { class: 'zx-modal__content', ref: 'content' })));
268
328
  dialog.dataset.state = 'closed';
269
- this.mountTarget().append(dialog);
329
+ this._mountInitial(dialog);
270
330
  return dialog;
271
331
  }
272
332
 
333
+ /**
334
+ * Gives a freshly rendered dialog its first home.
335
+ *
336
+ * A `scope` that is not in the document yet cannot say which theme it belongs to, and the
337
+ * `<body>` that `mountTarget()` would answer is outside it. The dialog then stays detached and
338
+ * `open()` places it, by which time the scope is in place. Called from `render()`, so it may
339
+ * read `this.options` but no instance state.
340
+ * @param {HTMLDialogElement} dialog The rendered dialog.
341
+ * @returns {void}
342
+ */
343
+ _mountInitial(dialog) {
344
+ if (this.options.scope != null && !connectedOverlayHost(this.options.scope)) return;
345
+ this.mountTarget().append(dialog);
346
+ }
347
+
273
348
  /**
274
349
  * Opens the modal in the browser top layer.
275
350
  * @returns {this}
@@ -285,16 +360,15 @@ export class Modal extends Component {
285
360
  this.#opener = document.activeElement instanceof Element ? document.activeElement : null;
286
361
  const host = this.mountTarget();
287
362
  if (this.el.parentElement !== host) host.append(this.el);
288
- this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
289
363
  this.el.returnValue = '';
290
364
  try {
291
365
  this._present();
292
366
  } catch (error) {
293
- delete this.el.dataset.zxOverlayOrder;
294
367
  // Nothing was presented, so nothing will close; a capture left behind would outlive its open.
295
368
  this.#opener = null;
296
369
  throw error;
297
370
  }
371
+ this._syncOverlay();
298
372
  this.el.dataset.state = 'open';
299
373
  this.emit('open');
300
374
  // Deferred by one microtask: the platform's own focusing step runs as part of presenting, and
@@ -347,6 +421,8 @@ export class Modal extends Component {
347
421
  if (this.#destroying) return;
348
422
  this.#destroying = true;
349
423
  if (this.isOpen()) this._dismiss();
424
+ // The queued close that would settle it never arrives once listeners are aborted.
425
+ this._releaseOverlay();
350
426
  super.destroy();
351
427
  }
352
428
 
@@ -443,11 +519,3 @@ function isBackdropClick(dialog, event) {
443
519
  return event.clientX < rect.left || event.clientX > rect.right ||
444
520
  event.clientY < rect.top || event.clientY > rect.bottom;
445
521
  }
446
-
447
- /** @returns {number} */
448
- function nextOverlayOrder() {
449
- const orders = Array.from(document.querySelectorAll('[data-zx-overlay-order]'), (element) =>
450
- Number(element.getAttribute('data-zx-overlay-order')) || 0
451
- );
452
- return Math.max(0, ...orders) + 1;
453
- }