@zeyos/zx 4.3.4 → 5.0.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 (231) hide show
  1. package/CHANGELOG.md +438 -0
  2. package/README.md +49 -10
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +217 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +33 -3
  6. package/dist/types/components/avatar/avatar.d.ts +46 -7
  7. package/dist/types/components/badge/badge.d.ts +21 -1
  8. package/dist/types/components/button/button.d.ts +23 -0
  9. package/dist/types/components/calendar/calendar.d.ts +19 -0
  10. package/dist/types/components/card/card.d.ts +7 -0
  11. package/dist/types/components/card-view/card-view.d.ts +11 -3
  12. package/dist/types/components/card-view/record-card.d.ts +7 -1
  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 +149 -0
  16. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  17. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  18. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  19. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  20. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  21. package/dist/types/components/datebox/datebox.d.ts +7 -2
  22. package/dist/types/components/dialog/dialog.d.ts +75 -6
  23. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  24. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  25. package/dist/types/components/field/field.d.ts +24 -2
  26. package/dist/types/components/fieldset/fieldset.d.ts +19 -1
  27. package/dist/types/components/file-item/file-item.d.ts +262 -0
  28. package/dist/types/components/file-list/file-list.d.ts +171 -0
  29. package/dist/types/components/filter/filter.d.ts +91 -36
  30. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  31. package/dist/types/components/form/form.d.ts +12 -1
  32. package/dist/types/components/kanban-view/kanban-card.d.ts +146 -0
  33. package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
  34. package/dist/types/components/launcher/launcher.d.ts +278 -26
  35. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  36. package/dist/types/components/menu-button/menu-button.d.ts +37 -42
  37. package/dist/types/components/message/message.d.ts +126 -17
  38. package/dist/types/components/modal/modal.d.ts +61 -2
  39. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  40. package/dist/types/components/panel/panel.d.ts +7 -0
  41. package/dist/types/components/search/search.d.ts +7 -0
  42. package/dist/types/components/select/color.d.ts +12 -0
  43. package/dist/types/components/select/glyphs.d.ts +124 -0
  44. package/dist/types/components/select/select.d.ts +15 -3
  45. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  46. package/dist/types/components/stat-tile/stat-tile.d.ts +288 -0
  47. package/dist/types/components/tabbox/tabbox.d.ts +16 -0
  48. package/dist/types/components/table/table.d.ts +190 -1
  49. package/dist/types/components/table-view/table-view.d.ts +11 -5
  50. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  51. package/dist/types/components/timebox/timebox.d.ts +7 -0
  52. package/dist/types/components/tree/tree.d.ts +13 -0
  53. package/dist/types/components/value-list/value-list.d.ts +8 -0
  54. package/dist/types/components/view/record-view.d.ts +96 -5
  55. package/dist/types/core/breakpoint.d.ts +1 -1
  56. package/dist/types/core/icons.d.ts +37 -22
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +15 -1
  59. package/dist/types/index.d.ts +16 -3
  60. package/dist/types/internal/app-rail.d.ts +10 -0
  61. package/dist/types/internal/menu-items.d.ts +134 -0
  62. package/dist/types/zeyos/connect.d.ts +6 -0
  63. package/dist/types/zeyos/form.d.ts +50 -9
  64. package/dist/types/zeyos/index.d.ts +1 -1
  65. package/dist/types/zeyos/query.d.ts +79 -0
  66. package/dist/types/zeyos/schema.d.ts +24 -1
  67. package/dist/types/zeyos/table.d.ts +100 -14
  68. package/dist/types/zeyos/view.d.ts +10 -0
  69. package/dist/zx-zeyos.esm.js +2206 -489
  70. package/dist/zx-zeyos.esm.min.js +56 -1
  71. package/dist/zx.css +3322 -724
  72. package/dist/zx.esm.js +12244 -5809
  73. package/dist/zx.esm.min.js +58 -3
  74. package/dist/zx.global.js +12246 -5811
  75. package/dist/zx.global.min.js +58 -3
  76. package/dist/zx.min.css +1 -1
  77. package/docs/api.json +1683 -146
  78. package/docs/llms.md +777 -110
  79. package/docs/llms.txt +14 -8
  80. package/package.json +1 -1
  81. package/src/components/account-menu/account-menu.css +35 -10
  82. package/src/components/activity-item/activity-item.css +137 -0
  83. package/src/components/activity-item/activity-item.js +602 -0
  84. package/src/components/activity-list/activity-list.css +99 -0
  85. package/src/components/activity-list/activity-list.js +409 -0
  86. package/src/components/app-icon/app-icon.css +1 -1
  87. package/src/components/app-rail/app-rail.css +191 -22
  88. package/src/components/app-sidebar/app-sidebar.css +349 -24
  89. package/src/components/app-sidebar/app-sidebar.js +86 -11
  90. package/src/components/aurora/aurora.css +5 -3
  91. package/src/components/avatar/avatar.css +32 -7
  92. package/src/components/avatar/avatar.js +72 -8
  93. package/src/components/badge/badge.css +54 -2
  94. package/src/components/badge/badge.js +29 -2
  95. package/src/components/button/button.css +74 -8
  96. package/src/components/button/button.js +44 -1
  97. package/src/components/calendar/calendar.css +46 -46
  98. package/src/components/calendar/calendar.js +33 -3
  99. package/src/components/card/card.css +2 -2
  100. package/src/components/card/card.js +12 -1
  101. package/src/components/card-view/card-view.css +10 -7
  102. package/src/components/card-view/card-view.js +25 -9
  103. package/src/components/card-view/record-card.js +7 -3
  104. package/src/components/chart/chart.css +4 -1
  105. package/src/components/chart/chart.js +12 -0
  106. package/src/components/check-button/check-button.css +10 -2
  107. package/src/components/checklist/checklist.css +9 -2
  108. package/src/components/checklist/checklist.js +11 -5
  109. package/src/components/checklist/field-adapter.js +3 -1
  110. package/src/components/code-editor/code-editor.css +68 -0
  111. package/src/components/code-editor/code-editor.js +306 -0
  112. package/src/components/code-editor/field-adapter.js +34 -0
  113. package/src/components/context-menu/context-menu.css +87 -5
  114. package/src/components/context-menu/context-menu.js +22 -30
  115. package/src/components/copy/copy-button.css +6 -0
  116. package/src/components/copy/copy-input.css +8 -3
  117. package/src/components/data-filter/data-filter.css +5 -4
  118. package/src/components/date-picker/date-picker.css +6 -6
  119. package/src/components/date-picker/date-picker.js +22 -8
  120. package/src/components/date-picker/date-range-picker.css +3 -3
  121. package/src/components/date-picker/month-picker.js +3 -3
  122. package/src/components/date-picker/time-picker.css +1 -1
  123. package/src/components/date-picker/time-picker.js +20 -8
  124. package/src/components/datebox/date-range-box.css +79 -2
  125. package/src/components/datebox/date-range-box.js +13 -2
  126. package/src/components/datebox/datebox.css +79 -2
  127. package/src/components/datebox/datebox.js +13 -2
  128. package/src/components/dialog/dialog.css +26 -0
  129. package/src/components/dialog/dialog.js +174 -70
  130. package/src/components/dropdown/dropdown.css +1 -1
  131. package/src/components/dropdown/dropdown.js +7 -5
  132. package/src/components/empty-state/empty-state.css +2 -2
  133. package/src/components/entity-ref/entity-ref.css +182 -0
  134. package/src/components/entity-ref/entity-ref.js +496 -0
  135. package/src/components/field/field.css +28 -9
  136. package/src/components/field/field.js +68 -26
  137. package/src/components/field-upload/field-upload.css +1 -1
  138. package/src/components/fieldset/fieldset.css +5 -3
  139. package/src/components/fieldset/fieldset.js +28 -5
  140. package/src/components/file-item/file-item.css +88 -0
  141. package/src/components/file-item/file-item.js +505 -0
  142. package/src/components/file-list/file-list.css +51 -0
  143. package/src/components/file-list/file-list.js +320 -0
  144. package/src/components/filter/filter.css +27 -1
  145. package/src/components/filter/filter.js +205 -47
  146. package/src/components/filter-panel/filter-panel.css +104 -0
  147. package/src/components/filter-panel/filter-panel.js +964 -0
  148. package/src/components/finder/finder.css +15 -13
  149. package/src/components/form/form.css +5 -4
  150. package/src/components/form/form.js +11 -2
  151. package/src/components/grid/grid.css +1 -1
  152. package/src/components/groupbox/groupbox.css +1 -1
  153. package/src/components/kanban-view/kanban-card.js +247 -0
  154. package/src/components/kanban-view/kanban-view.css +226 -8
  155. package/src/components/kanban-view/kanban-view.js +255 -12
  156. package/src/components/launcher/launcher.css +107 -25
  157. package/src/components/launcher/launcher.js +557 -63
  158. package/src/components/list-toolbar/list-toolbar.css +194 -0
  159. package/src/components/list-toolbar/list-toolbar.js +851 -0
  160. package/src/components/master-panel/master-panel.css +2 -0
  161. package/src/components/menu-button/menu-button.css +87 -5
  162. package/src/components/menu-button/menu-button.js +86 -46
  163. package/src/components/message/message.css +38 -5
  164. package/src/components/message/message.js +230 -17
  165. package/src/components/modal/modal.css +15 -0
  166. package/src/components/modal/modal.js +159 -3
  167. package/src/components/navigation-bar/navigation-bar.css +95 -11
  168. package/src/components/navigation-bar/navigation-bar.js +171 -4
  169. package/src/components/number-field/field-adapter.js +1 -0
  170. package/src/components/number-field/number-field.css +70 -0
  171. package/src/components/number-field/number-field.js +2 -0
  172. package/src/components/pagination/pagination.css +8 -6
  173. package/src/components/panel/panel.css +1 -1
  174. package/src/components/panel/panel.js +13 -2
  175. package/src/components/rating/rating.css +7 -0
  176. package/src/components/search/search.js +17 -5
  177. package/src/components/select/color.js +22 -0
  178. package/src/components/select/field-adapter.js +6 -1
  179. package/src/components/select/glyphs.js +165 -0
  180. package/src/components/select/select.css +138 -45
  181. package/src/components/select/select.js +86 -47
  182. package/src/components/sheet/sheet.css +11 -8
  183. package/src/components/slider/slider.css +16 -2
  184. package/src/components/slider/slider.js +1 -1
  185. package/src/components/sort-control/sort-control.css +20 -0
  186. package/src/components/sort-control/sort-control.js +140 -0
  187. package/src/components/stat-tile/stat-tile.css +208 -0
  188. package/src/components/stat-tile/stat-tile.js +601 -0
  189. package/src/components/tabbox/tabbox.css +32 -27
  190. package/src/components/tabbox/tabbox.js +17 -2
  191. package/src/components/table/table.css +155 -31
  192. package/src/components/table/table.js +386 -33
  193. package/src/components/table-view/table-view.css +8 -2
  194. package/src/components/table-view/table-view.js +21 -6
  195. package/src/components/tag-picker/tag-picker.css +86 -6
  196. package/src/components/tag-picker/tag-picker.js +25 -8
  197. package/src/components/timebox/timebox.css +22 -2
  198. package/src/components/timebox/timebox.js +18 -5
  199. package/src/components/toggle/toggle.css +12 -1
  200. package/src/components/toolbar/toolbar.js +2 -2
  201. package/src/components/tooltip/tooltip.css +4 -0
  202. package/src/components/tooltip/tooltip.js +10 -3
  203. package/src/components/tree/tree.css +25 -16
  204. package/src/components/tree/tree.js +31 -0
  205. package/src/components/value-list/value-list.css +3 -3
  206. package/src/components/value-list/value-list.js +22 -6
  207. package/src/components/view/record-view.css +5 -0
  208. package/src/components/view/record-view.js +100 -7
  209. package/src/core/breakpoint.js +1 -1
  210. package/src/core/icons.js +259 -57
  211. package/src/core/motion.js +147 -0
  212. package/src/core/overlay-host.js +21 -2
  213. package/src/index.js +30 -3
  214. package/src/internal/app-rail.js +18 -1
  215. package/src/internal/menu-items.js +142 -0
  216. package/src/zeyos/connect.js +6 -2
  217. package/src/zeyos/form.js +67 -27
  218. package/src/zeyos/index.js +1 -1
  219. package/src/zeyos/query.js +153 -0
  220. package/src/zeyos/schema.js +144 -27
  221. package/src/zeyos/table.js +68 -5
  222. package/src/zeyos/view.js +6 -0
  223. package/styles/base.css +56 -0
  224. package/styles/icons.css +11 -1
  225. package/styles/print.css +192 -0
  226. package/styles/tokens/dark.css +113 -37
  227. package/styles/tokens/global.css +121 -27
  228. package/styles/tokens/semantic.css +100 -31
  229. package/styles/tokens/themes.css +7 -6
  230. package/styles/zx.css +11 -1
  231. package/styles/tokens/density.css +0 -9
@@ -1,15 +1,32 @@
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
4
  import { overlayHost } from '../../core/overlay-host.js';
5
+ import { button } from '../button/button.js';
5
6
 
6
7
  /** @typedef {'info'|'success'|'warning'|'error'} MessageKind */
7
8
 
9
+ /**
10
+ * The one command a message may carry, drawn as a small button beside its text.
11
+ * @typedef {Object} MessageAction
12
+ * @property {string} label Button text: a verb naming what happens ("Undo", "Approve", "Retry").
13
+ * @property {(event: MouseEvent) => void} [onclick] Runs when the button is activated, by pointer
14
+ * or keyboard; the message closes right after it.
15
+ * @property {'default'|'primary'|'danger'|'ghost'} [kind='default'] Button kind; the default is the
16
+ * secondary button.
17
+ */
18
+
8
19
  /**
9
20
  * @typedef {Object} MessageShowOptions
10
21
  * @property {MessageKind|'danger'} [kind='info'] Message intent (`danger` aliases `error`).
11
- * @property {number} [timeout=4000] Auto-dismiss delay in milliseconds; zero persists.
22
+ * @property {number} [timeout=4000] Auto-dismiss delay in milliseconds; zero persists. A message
23
+ * with an `action` stays at least 8000ms.
12
24
  * @property {boolean} [closable=true] Whether to render a close button.
25
+ * @property {MessageAction|null} [action=null] A command button inside the message. The message
26
+ * never times out while the pointer is over it or focus is inside it.
27
+ * @property {Node|{toElement: () => Node|null}|null} [leading=null] Visual shown in place of the
28
+ * kind icon, such as an Avatar's element. It is decorative — the message text must say what it
29
+ * shows.
13
30
  * @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns a floating toast.
14
31
  */
15
32
 
@@ -21,7 +38,15 @@ import { overlayHost } from '../../core/overlay-host.js';
21
38
  * @property {number} [maxVisible=5] Maximum number of visible messages.
22
39
  */
23
40
 
24
- /** @typedef {{close: () => void}} MessageHandle */
41
+ /**
42
+ * Returned by every `show()`: closes that message early. Closing one that already closed, or one
43
+ * still waiting in the queue, is safe.
44
+ * @typedef {Object} MessageHandle
45
+ * @property {() => void} close Closes the message.
46
+ */
47
+
48
+ /** The shortest time a message that carries an action stays, in milliseconds. */
49
+ export const MESSAGE_ACTION_MIN_TIMEOUT = 8000;
25
50
 
26
51
  /**
27
52
  * @typedef {Object} ProgressHandle
@@ -135,7 +160,7 @@ export class Message extends Component {
135
160
  maxVisible: 5
136
161
  };
137
162
 
138
- /** @type {WeakMap<Element, Message>} */
163
+ /** @type {WeakMap<Element, Message>} Floating region per overlay host. */
139
164
  static #floating = new WeakMap();
140
165
 
141
166
  /** @returns {HTMLElement} */
@@ -145,6 +170,8 @@ export class Message extends Component {
145
170
  this._createdRoot = created;
146
171
  this._original = created ? null : snapshot(root);
147
172
  this._entries = new Set();
173
+ /** @type {Element|null} The overlay host this instance is the floating region of; null when inline. */
174
+ this._floatingHost = null;
148
175
  this._queue = new MessageQueue(normalizeLimit(this.options.maxVisible), (entry) => {
149
176
  this._activate(entry);
150
177
  });
@@ -161,10 +188,13 @@ export class Message extends Component {
161
188
  * @returns {MessageHandle}
162
189
  */
163
190
  show(msg, options = {}) {
191
+ const action = normalizeMessageAction(options.action);
164
192
  const settings = {
165
193
  kind: normalizeKind(options.kind ?? this.options.kind),
166
- timeout: normalizeTimeout(options.timeout ?? this.options.timeout),
167
- closable: options.closable ?? Boolean(this.options.closable)
194
+ timeout: messageTimeout(options.timeout ?? this.options.timeout, action !== null),
195
+ closable: options.closable ?? Boolean(this.options.closable),
196
+ action,
197
+ leading: resolveLeading(options.leading)
168
198
  };
169
199
  const entry = this._createEntry(msg, settings);
170
200
  this._entries.add(entry);
@@ -232,10 +262,33 @@ export class Message extends Component {
232
262
  return this.#floatingRegion(options.scope)._progress(text);
233
263
  }
234
264
 
265
+ /**
266
+ * Removes a floating region at once, toasts and all, without waiting for them to time out.
267
+ *
268
+ * A region already removes itself when its last toast closes, and one whose host has left the
269
+ * document is replaced on the next toast, so this is only for a host being torn down while
270
+ * toasts are still showing — an application view unmounting its themed shell, say.
271
+ * @param {Element|string} scope Element or selector whose nearest Zx theme scope owns the region,
272
+ * as passed to `show()`.
273
+ * @returns {boolean} Whether there was a region to remove.
274
+ */
275
+ static disposeRegion(scope) {
276
+ if (scope == null) return false;
277
+ const context = resolveElement(scope);
278
+ if (!context) return false;
279
+ const host = overlayHost(context);
280
+ const region = Message.#floating.get(host);
281
+ if (!region) return false;
282
+ Message.#floating.delete(host);
283
+ region._disposeFloating();
284
+ return true;
285
+ }
286
+
235
287
  /** @returns {void} */
236
288
  destroy() {
237
289
  if (this._cleaned) return;
238
290
  this._cleaned = true;
291
+ this._floatingHost = null;
239
292
  for (const entry of this._entries) {
240
293
  clearEntryTimers(entry);
241
294
  entry.node.remove();
@@ -288,13 +341,13 @@ export class Message extends Component {
288
341
 
289
342
  /**
290
343
  * @param {string|Node} msg
291
- * @param {{kind: MessageKind, timeout: number, closable: boolean}} settings
344
+ * @param {{kind: MessageKind, timeout: number, closable: boolean, action?: MessageAction|null, leading?: Node|null}} settings
292
345
  * @param {{pct: number, text: string}|null} [progress=null]
293
346
  * @returns {any}
294
347
  */
295
348
  _createEntry(msg, settings, progress = null) {
296
349
  const iconHost = h('span', { class: 'zx-message__icon', ariaHidden: 'true' },
297
- icon(settings.kind, { size: 18 })
350
+ settings.leading ?? icon(settings.kind, { size: 18 })
298
351
  );
299
352
  const content = h('div', { class: 'zx-message__content' });
300
353
  if (isNode(msg)) content.append(msg);
@@ -315,9 +368,13 @@ export class Message extends Component {
315
368
  timer: null,
316
369
  exitTimer: null,
317
370
  closed: false,
371
+ hovered: false,
372
+ focused: false,
373
+ returnFocus: null,
318
374
  progressBar: null,
319
375
  progressFill: null
320
376
  };
377
+ if (settings.leading) node.dataset.leading = '';
321
378
 
322
379
  if (progress) {
323
380
  node.dataset.progress = '';
@@ -336,18 +393,56 @@ export class Message extends Component {
336
393
  syncProgress(entry, progress);
337
394
  }
338
395
 
396
+ if (settings.action) {
397
+ const { label, kind, onclick } = settings.action;
398
+ const control = button({ label, kind, size: 'sm' });
399
+ control.classList.add('zx-message__action');
400
+ node.append(control);
401
+ this.listen(control, 'click', (event) => {
402
+ if (entry.closed) return;
403
+ try {
404
+ onclick?.(/** @type {MouseEvent} */ (event));
405
+ } finally {
406
+ this._close(entry);
407
+ }
408
+ });
409
+ }
410
+
339
411
  if (settings.closable) {
340
412
  const close = h('button', {
341
413
  class: 'zx-icon-btn zx-message__close',
342
414
  type: 'button',
343
- ariaLabel: 'Close message',
344
- title: 'Close'
415
+ ariaLabel: this._message('message.close', 'Close message'),
416
+ title: this._message('message.closeTitle', 'Close')
345
417
  }, icon('x', { size: 14 }));
346
418
  node.append(close);
347
419
  this.listen(close, 'click', () => this._close(entry));
348
420
  }
349
- this.listen(node, 'mouseenter', () => this._pause(entry));
350
- this.listen(node, 'mouseleave', () => this._resume(entry));
421
+ // Hover and focus each hold the timer (WCAG 2.2.1): a reader moving to the action or the close
422
+ // button must not have the message vanish under them, and it counts down again only once
423
+ // neither holds it.
424
+ this.listen(node, 'mouseenter', () => {
425
+ entry.hovered = true;
426
+ this._pause(entry);
427
+ });
428
+ this.listen(node, 'mouseleave', () => {
429
+ entry.hovered = false;
430
+ this._resume(entry);
431
+ });
432
+ this.listen(node, 'focusin', (event) => {
433
+ if (!entry.focused) {
434
+ const from = /** @type {FocusEvent} */ (event).relatedTarget;
435
+ entry.returnFocus = isNode(from) && !node.contains(/** @type {Node} */ (from)) ? from : null;
436
+ }
437
+ entry.focused = true;
438
+ this._pause(entry);
439
+ });
440
+ this.listen(node, 'focusout', (event) => {
441
+ const to = /** @type {FocusEvent} */ (event).relatedTarget;
442
+ if (isNode(to) && node.contains(/** @type {Node} */ (to))) return;
443
+ entry.focused = false;
444
+ this._resume(entry);
445
+ });
351
446
  return entry;
352
447
  }
353
448
 
@@ -361,7 +456,7 @@ export class Message extends Component {
361
456
 
362
457
  /** @param {any} entry @returns {void} */
363
458
  _schedule(entry) {
364
- if (entry.closed || entry.remaining <= 0) return;
459
+ if (entry.closed || entry.remaining <= 0 || entry.hovered || entry.focused) return;
365
460
  entry.startedAt = Date.now();
366
461
  entry.timer = setTimeout(() => this._close(entry), entry.remaining);
367
462
  }
@@ -376,7 +471,7 @@ export class Message extends Component {
376
471
 
377
472
  /** @param {any} entry @returns {void} */
378
473
  _resume(entry) {
379
- if (entry.closed || entry.timer !== null) return;
474
+ if (entry.closed || entry.timer !== null || entry.hovered || entry.focused) return;
380
475
  if (entry.remaining <= 0) {
381
476
  this._close(entry);
382
477
  return;
@@ -389,10 +484,12 @@ export class Message extends Component {
389
484
  if (entry.closed) return;
390
485
  entry.closed = true;
391
486
  clearEntryTimers(entry);
487
+ returnFocus(entry);
392
488
  if (!this._queue.isActive(entry)) {
393
489
  this._queue.remove(entry);
394
490
  entry.node.remove();
395
491
  this._entries.delete(entry);
492
+ this._releaseIfEmpty();
396
493
  return;
397
494
  }
398
495
 
@@ -415,13 +512,57 @@ export class Message extends Component {
415
512
  entry.node.remove();
416
513
  this._entries.delete(entry);
417
514
  this._queue.remove(entry);
515
+ this._releaseIfEmpty();
516
+ }
517
+
518
+ /**
519
+ * Takes a floating region out of the document once its last toast has gone, so an emptied host
520
+ * is not left holding a region nobody uses. Inline areas stay: their element is the caller's.
521
+ * @returns {void}
522
+ */
523
+ _releaseIfEmpty() {
524
+ if (!this._floatingHost || this._entries.size > 0) return;
525
+ Message.#release(this);
418
526
  }
419
527
 
420
- /** @param {Element|string|null|undefined} scope @returns {Message} */
528
+ /**
529
+ * Destroys a floating region and removes its element.
530
+ * @returns {void}
531
+ */
532
+ _disposeFloating() {
533
+ const region = this.el;
534
+ this.destroy();
535
+ region.remove();
536
+ }
537
+
538
+ /**
539
+ * Resolves a message through the host translator, falling back to the built-in English text.
540
+ * @param {string} key Message key.
541
+ * @param {string} fallback English default.
542
+ * @returns {string}
543
+ */
544
+ _message(key, fallback) {
545
+ const message = this.msg(key);
546
+ return message === key ? fallback : message;
547
+ }
548
+
549
+ /**
550
+ * Finds or creates the floating region for a scope. A cached region is reused only while it is
551
+ * still where it was put: a host emptied by `replaceChildren()` would otherwise keep receiving
552
+ * toasts in a node nobody can see.
553
+ * @param {Element|string|null|undefined} scope
554
+ * @returns {Message}
555
+ */
421
556
  static #floatingRegion(scope) {
422
- const host = overlayHost(scope ?? null);
557
+ let host = overlayHost(scope ?? null);
558
+ // A scope that has itself left the document cannot show anything; the toast still must be seen.
559
+ if (!host.isConnected) host = overlayHost(null);
423
560
  const existing = this.#floating.get(host);
424
- if (existing) return existing;
561
+ if (existing && existing.el.isConnected && existing.el.parentElement === host) return existing;
562
+ if (existing) {
563
+ this.#floating.delete(host);
564
+ existing._disposeFloating();
565
+ }
425
566
  const region = h('div', {
426
567
  class: 'zx-message-region',
427
568
  popover: 'manual',
@@ -430,10 +571,18 @@ export class Message extends Component {
430
571
  });
431
572
  host.append(region);
432
573
  const floating = new Message(region);
574
+ floating._floatingHost = host;
433
575
  this.#floating.set(host, floating);
434
576
  if (typeof region.showPopover === 'function') region.showPopover();
435
577
  return floating;
436
578
  }
579
+
580
+ /** @param {Message} region Floating region whose last toast closed. @returns {void} */
581
+ static #release(region) {
582
+ const host = region._floatingHost;
583
+ if (host && Message.#floating.get(host) === region) Message.#floating.delete(host);
584
+ region._disposeFloating();
585
+ }
437
586
  }
438
587
 
439
588
  /** @param {unknown} value @returns {MessageKind} */
@@ -449,6 +598,70 @@ function normalizeTimeout(value) {
449
598
  return Number.isFinite(timeout) ? Math.max(0, timeout) : 4000;
450
599
  }
451
600
 
601
+ /**
602
+ * Settles how long a message stays. A message that carries an action stays at least
603
+ * `MESSAGE_ACTION_MIN_TIMEOUT`, so there is time to reach it; zero still means it stays until
604
+ * closed, and anything that is not a finite number falls back to the 4000ms default.
605
+ * @param {unknown} timeout Requested auto-dismiss delay in milliseconds.
606
+ * @param {boolean} [hasAction=false] Whether the message carries an action.
607
+ * @returns {number}
608
+ */
609
+ export function messageTimeout(timeout, hasAction = false) {
610
+ const settled = normalizeTimeout(timeout);
611
+ return hasAction && settled > 0 ? Math.max(settled, MESSAGE_ACTION_MIN_TIMEOUT) : settled;
612
+ }
613
+
614
+ /**
615
+ * Copies a message action into the shape the message renders, or returns null when there is
616
+ * nothing to render: no object, or no label. An unknown `kind` reads as the default, secondary
617
+ * button, and a missing `onclick` leaves a button that only closes the message.
618
+ * @param {unknown} value Action descriptor.
619
+ * @returns {{label: string, kind: 'default'|'primary'|'danger'|'ghost', onclick: ((event: MouseEvent) => void)|null}|null}
620
+ */
621
+ export function normalizeMessageAction(value) {
622
+ if (!value || typeof value !== 'object') return null;
623
+ const action = /** @type {Record<string, unknown>} */ (value);
624
+ const label = action.label == null ? '' : String(action.label).trim();
625
+ if (!label) return null;
626
+ const kind = ['primary', 'danger', 'ghost'].includes(String(action.kind))
627
+ ? /** @type {'primary'|'danger'|'ghost'} */ (action.kind) : 'default';
628
+ return {
629
+ label,
630
+ kind,
631
+ onclick: typeof action.onclick === 'function'
632
+ ? /** @type {(event: MouseEvent) => void} */ (action.onclick) : null
633
+ };
634
+ }
635
+
636
+ /**
637
+ * Accepts a node, or a component such as an Avatar, for the leading slot.
638
+ * @param {any} value Leading content.
639
+ * @returns {Node|null}
640
+ */
641
+ function resolveLeading(value) {
642
+ if (isNode(value)) return /** @type {Node} */ (value);
643
+ if (value && typeof value === 'object' && typeof value.toElement === 'function') {
644
+ const element = value.toElement();
645
+ return isNode(element) ? element : null;
646
+ }
647
+ return null;
648
+ }
649
+
650
+ /**
651
+ * Hands focus back to where it came from when a message closes with focus inside it, so a keyboard
652
+ * reader is not dropped on the page body with the element they were on gone.
653
+ * @param {any} entry Message entry.
654
+ * @returns {void}
655
+ */
656
+ function returnFocus(entry) {
657
+ const root = entry.node.getRootNode?.();
658
+ const active = root?.activeElement ?? globalThis.document?.activeElement;
659
+ if (!active || !entry.node.contains(active)) return;
660
+ const target = entry.returnFocus;
661
+ entry.returnFocus = null;
662
+ if (target?.isConnected && typeof target.focus === 'function') target.focus({ preventScroll: true });
663
+ }
664
+
452
665
  /** @param {unknown} value @returns {number} */
453
666
  function normalizeLimit(value) {
454
667
  const limit = Number(value);
@@ -32,6 +32,21 @@
32
32
  padding: var(--zx-space-5);
33
33
  }
34
34
 
35
+ /*
36
+ * The panel takes focus itself only as a fallback, when it holds nothing focusable at all. It is a
37
+ * surface rather than a control, so that programmatic focus paints no ring; a reader the browser
38
+ * reports as navigating by keyboard still gets one, because otherwise focus would be inside the
39
+ * overlay with nothing on screen saying so.
40
+ */
41
+ .zx-modal:focus {
42
+ outline: none;
43
+ }
44
+
45
+ .zx-modal:focus-visible {
46
+ outline: 2px solid var(--zx-color-ring);
47
+ outline-offset: calc(-1 * var(--zx-space-1));
48
+ }
49
+
35
50
  /*
36
51
  * `:modal` rather than `[open]`: only a dialog opened with `showModal()` blocks the page, and a
37
52
  * non-modal one (Sheet's `modal: false`) must leave the page scrollable behind it.
@@ -1,6 +1,15 @@
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, overlayHost } from '../../core/overlay-host.js';
4
+
5
+ /**
6
+ * Controls initial focus may be placed on: what a reader reaches with Tab, rather than everything
7
+ * the platform will focus programmatically.
8
+ */
9
+ const FOCUSABLE_SELECTOR = [
10
+ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])',
11
+ 'textarea:not([disabled])', '[contenteditable="true"]', '[tabindex]:not([tabindex="-1"])'
12
+ ].join(',');
4
13
 
5
14
  /**
6
15
  * @typedef {Object} ModalOptions
@@ -9,7 +18,7 @@ import { overlayHost } from '../../core/overlay-host.js';
9
18
  * @property {boolean} [closable=true] Whether Escape may close the modal.
10
19
  * @property {boolean} [lightDismiss=false] Whether a backdrop click closes the modal.
11
20
  * @property {boolean} [destroyOnClose=false] Whether to destroy the modal after it closes.
12
- * @property {Element|string|null} [scope=null] Element whose nearest Zx theme scope owns the overlay; defaults to the opener.
21
+ * @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.
13
22
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open event listener.
14
23
  * @property {(event: CustomEvent<{result: unknown}>) => void} [onclose] Close event listener.
15
24
  * @property {(event: CustomEvent<Record<string, never>>) => void} [oncancel] Cancel event listener.
@@ -18,6 +27,15 @@ import { overlayHost } from '../../core/overlay-host.js';
18
27
  /**
19
28
  * Thin native-dialog overlay with lifecycle-safe content and dismissal behavior.
20
29
  * The constructor always creates and appends a new dialog; its target argument is ignored.
30
+ *
31
+ * Accessibility is the platform's wherever the platform has an answer. The root is a real
32
+ * `<dialog>` and `_show()` presents it with `showModal()`, so focus containment, the inertness of
33
+ * everything behind it, Escape, the top layer and the implicit `dialog` role all come from the
34
+ * browser — there is no hand-written focus trap here, and adding one on top of `:modal` would be
35
+ * two traps fighting over the same Tab key. What the platform leaves undone is the rest of this
36
+ * class's accessibility work: it stops initial focus at the panel instead of the first control,
37
+ * and it restores focus to the opener only for its own native presentation, not for the popover
38
+ * one a non-modal subclass uses.
21
39
  * @fires Modal#open
22
40
  * @fires Modal#close
23
41
  * @fires Modal#cancel
@@ -42,6 +60,8 @@ export class Modal extends Component {
42
60
  #presentation = 0;
43
61
  /** @type {number[]} */
44
62
  #nativeClosingPresentations = [];
63
+ /** @type {Element|null} Element that had focus when the overlay was opened. */
64
+ #opener = null;
45
65
 
46
66
  /**
47
67
  * Creates a modal in the configured theme scope, or at document level when none is present.
@@ -64,6 +84,14 @@ export class Modal extends Component {
64
84
  if (!this.options.lightDismiss || event.target !== this.el || !isBackdropClick(this.el, event)) return;
65
85
  this.close();
66
86
  });
87
+ /*
88
+ * One listener for every close path, because `close` is the one event all four converge on:
89
+ * a footer button and a programmatic `close()` go through `_dismiss()`, Escape and a native
90
+ * dismissal arrive as the platform's own `close`, and light dismiss calls `close()` itself.
91
+ * A subclass that traps focus registers its own restore after this one and so wins, which is
92
+ * what keeps `Sheet`'s trap authoritative over its own capture.
93
+ */
94
+ this.on('close', () => this.#restoreFocus());
67
95
  }
68
96
 
69
97
  /**
@@ -112,6 +140,23 @@ export class Modal extends Component {
112
140
  _present() {
113
141
  this._show();
114
142
  this.#presentation += 1;
143
+ this.#syncModality();
144
+ }
145
+
146
+ /**
147
+ * Mirrors the presentation's real modality onto the panel as `aria-modal`.
148
+ *
149
+ * Asked of the platform (`:modal`) rather than of an option, so it stays true through every
150
+ * presentation a subclass chooses: `showModal()` is modal, a popover or an in-flow `show()` is
151
+ * not, and a sheet that moves between them is re-answered when it is presented again. The
152
+ * `<dialog>` element already carries the implicit `dialog` role and, while `:modal`, the
153
+ * implicit modal semantics — the attribute states them for assistive technology that reads the
154
+ * markup rather than the top-layer state.
155
+ * @returns {void}
156
+ */
157
+ #syncModality() {
158
+ if (this.el.matches(':modal')) this.el.setAttribute('aria-modal', 'true');
159
+ else if (this.el.getAttribute('aria-modal') === 'true') this.el.removeAttribute('aria-modal');
115
160
  }
116
161
 
117
162
  /**
@@ -158,6 +203,11 @@ export class Modal extends Component {
158
203
  }
159
204
  this.el.dataset.state = 'closed';
160
205
  delete this.el.dataset.zxOverlayOrder;
206
+ /*
207
+ * Only the flag this class raised. A subclass presenting non-modally writes
208
+ * `aria-modal="false"` and owns that value across its own presentations.
209
+ */
210
+ if (this.el.getAttribute('aria-modal') === 'true') this.el.removeAttribute('aria-modal');
161
211
  const pending = closingPresentation === undefined
162
212
  ? undefined
163
213
  : this.#pendingResults.get(closingPresentation);
@@ -168,6 +218,45 @@ export class Modal extends Component {
168
218
  return true;
169
219
  }
170
220
 
221
+ /**
222
+ * Returns focus to whatever held it when the overlay opened.
223
+ *
224
+ * A closing `<dialog>` restores focus itself, but only for its own native presentation and only
225
+ * while the opener is still where it was; doing it here covers the popover presentation a
226
+ * non-modal subclass uses, and covers `destroyOnClose`, which takes the opener's document
227
+ * position out from under the platform's own restore.
228
+ * @returns {void}
229
+ */
230
+ #restoreFocus() {
231
+ const opener = /** @type {HTMLElement|null} */ (this.#opener);
232
+ this.#opener = null;
233
+ if (opener?.isConnected && typeof opener.focus === 'function') opener.focus();
234
+ }
235
+
236
+ /**
237
+ * Places initial focus inside the panel.
238
+ *
239
+ * `showModal()` focuses an `autofocus` control when the content declares one and otherwise stops
240
+ * at the dialog element, which leaves a reader one Tab short of the first control. This keeps
241
+ * that preference — so a `Dialog` footer button marked `autofocus` still wins — and then falls
242
+ * through to the first focusable descendant, with the panel itself as the last resort so focus
243
+ * is never left outside the overlay.
244
+ * @returns {void}
245
+ */
246
+ _focusInitial() {
247
+ if (!this.isOpen()) return;
248
+ const target = initialFocusTarget(this.el);
249
+ if (target) {
250
+ target.focus();
251
+ return;
252
+ }
253
+ // A panel holding nothing focusable has to take focus itself, and is made focusable only for
254
+ // that: `tabindex="-1"` is programmatic focus, so it adds nothing to the Tab order.
255
+ const panel = /** @type {HTMLElement} */ (this.el);
256
+ if (!panel.hasAttribute('tabindex')) panel.setAttribute('tabindex', '-1');
257
+ panel.focus();
258
+ }
259
+
171
260
  /**
172
261
  * Creates the owned native dialog.
173
262
  * @returns {HTMLDialogElement}
@@ -177,10 +266,25 @@ export class Modal extends Component {
177
266
  class: 'zx-modal'
178
267
  }, h('div', { class: 'zx-modal__content', ref: 'content' })));
179
268
  dialog.dataset.state = 'closed';
180
- this.mountTarget().append(dialog);
269
+ this._mountInitial(dialog);
181
270
  return dialog;
182
271
  }
183
272
 
273
+ /**
274
+ * Gives a freshly rendered dialog its first home.
275
+ *
276
+ * A `scope` that is not in the document yet cannot say which theme it belongs to, and the
277
+ * `<body>` that `mountTarget()` would answer is outside it. The dialog then stays detached and
278
+ * `open()` places it, by which time the scope is in place. Called from `render()`, so it may
279
+ * read `this.options` but no instance state.
280
+ * @param {HTMLDialogElement} dialog The rendered dialog.
281
+ * @returns {void}
282
+ */
283
+ _mountInitial(dialog) {
284
+ if (this.options.scope != null && !connectedOverlayHost(this.options.scope)) return;
285
+ this.mountTarget().append(dialog);
286
+ }
287
+
184
288
  /**
185
289
  * Opens the modal in the browser top layer.
186
290
  * @returns {this}
@@ -188,6 +292,12 @@ export class Modal extends Component {
188
292
  */
189
293
  open() {
190
294
  if (this.isOpen()) return this;
295
+ /*
296
+ * Captured before anything is presented, so it is the element the reader was actually on
297
+ * rather than whatever the overlay is about to focus. A subclass that traps focus before
298
+ * delegating here has already moved it and restores its own capture afterwards.
299
+ */
300
+ this.#opener = document.activeElement instanceof Element ? document.activeElement : null;
191
301
  const host = this.mountTarget();
192
302
  if (this.el.parentElement !== host) host.append(this.el);
193
303
  this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
@@ -196,10 +306,15 @@ export class Modal extends Component {
196
306
  this._present();
197
307
  } catch (error) {
198
308
  delete this.el.dataset.zxOverlayOrder;
309
+ // Nothing was presented, so nothing will close; a capture left behind would outlive its open.
310
+ this.#opener = null;
199
311
  throw error;
200
312
  }
201
313
  this.el.dataset.state = 'open';
202
314
  this.emit('open');
315
+ // Deferred by one microtask: the platform's own focusing step runs as part of presenting, and
316
+ // this has to be the one that settles, not the one it overwrites.
317
+ queueMicrotask(() => this._focusInitial());
203
318
  return this;
204
319
  }
205
320
 
@@ -278,6 +393,47 @@ export class Modal extends Component {
278
393
  * @type {CustomEvent<Record<string, never>>}
279
394
  */
280
395
 
396
+ /**
397
+ * Picks the control initial focus belongs on inside an overlay panel: an `autofocus` control when
398
+ * the content named one, the first focusable descendant otherwise, and null when the panel holds
399
+ * neither and must take focus itself.
400
+ *
401
+ * Candidates in a hidden, `aria-hidden` or `inert` subtree are skipped, because `focus()` on one
402
+ * is a silent no-op: a `Dialog` whose header close button is hidden by `closable: false`, or whose
403
+ * other registered views sit hidden in the same body, would otherwise "focus" nothing at all and
404
+ * leave the reader with no way into the overlay.
405
+ * @param {Element} root Overlay panel.
406
+ * @returns {HTMLElement|null}
407
+ */
408
+ export function initialFocusTarget(root) {
409
+ const declared = root.querySelector('[autofocus]:not([disabled])');
410
+ if (declared && isFocusable(declared, root)) return /** @type {HTMLElement} */ (declared);
411
+ for (const candidate of root.querySelectorAll(FOCUSABLE_SELECTOR)) {
412
+ if (isFocusable(candidate, root)) return /** @type {HTMLElement} */ (candidate);
413
+ }
414
+ return null;
415
+ }
416
+
417
+ /**
418
+ * Whether a candidate can take focus from where it sits inside the panel.
419
+ *
420
+ * Duck-typed on `focus` rather than tested against `HTMLElement`, so a focusable SVG or MathML
421
+ * element inside the panel is a candidate rather than a silently skipped one.
422
+ * @param {Element} element Candidate found by the focusable selector.
423
+ * @param {Element} root Panel the search is bounded by.
424
+ * @returns {boolean}
425
+ */
426
+ function isFocusable(element, root) {
427
+ if (typeof (/** @type {HTMLElement} */ (element).focus) !== 'function') return false;
428
+ for (let node = /** @type {Element|null} */ (element); node; node = node.parentElement) {
429
+ if (/** @type {HTMLElement} */ (node).hidden) return false;
430
+ if (node.getAttribute('aria-hidden') === 'true') return false;
431
+ if (node.hasAttribute('inert')) return false;
432
+ if (node === root) break;
433
+ }
434
+ return true;
435
+ }
436
+
281
437
  /**
282
438
  * @param {Element} target
283
439
  * @param {Node|string|number|{toElement: () => Node|null}|null|undefined} content