@zeyos/zx 4.4.0 → 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 (182) hide show
  1. package/CHANGELOG.md +283 -0
  2. package/README.md +42 -10
  3. package/dist/types/components/activity-list/activity-list.d.ts +22 -0
  4. package/dist/types/components/app-sidebar/app-sidebar.d.ts +5 -3
  5. package/dist/types/components/avatar/avatar.d.ts +46 -7
  6. package/dist/types/components/badge/badge.d.ts +21 -1
  7. package/dist/types/components/button/button.d.ts +15 -0
  8. package/dist/types/components/chart/chart.d.ts +10 -0
  9. package/dist/types/components/checklist/checklist.d.ts +9 -0
  10. package/dist/types/components/code-editor/code-editor.d.ts +6 -17
  11. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  12. package/dist/types/components/datebox/datebox.d.ts +7 -2
  13. package/dist/types/components/dialog/dialog.d.ts +46 -6
  14. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  15. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  16. package/dist/types/components/field/field.d.ts +24 -2
  17. package/dist/types/components/fieldset/fieldset.d.ts +19 -1
  18. package/dist/types/components/file-item/file-item.d.ts +34 -6
  19. package/dist/types/components/form/form.d.ts +12 -1
  20. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  21. package/dist/types/components/launcher/launcher.d.ts +260 -15
  22. package/dist/types/components/list-toolbar/list-toolbar.d.ts +1 -1
  23. package/dist/types/components/menu-button/menu-button.d.ts +33 -5
  24. package/dist/types/components/message/message.d.ts +119 -17
  25. package/dist/types/components/modal/modal.d.ts +13 -2
  26. package/dist/types/components/select/color.d.ts +12 -0
  27. package/dist/types/components/select/glyphs.d.ts +124 -0
  28. package/dist/types/components/select/select.d.ts +11 -3
  29. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  30. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  31. package/dist/types/components/table/table.d.ts +46 -1
  32. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  33. package/dist/types/components/tree/tree.d.ts +13 -0
  34. package/dist/types/core/breakpoint.d.ts +1 -1
  35. package/dist/types/core/icons.d.ts +23 -25
  36. package/dist/types/core/motion.d.ts +59 -0
  37. package/dist/types/core/overlay-host.d.ts +15 -1
  38. package/dist/types/index.d.ts +5 -3
  39. package/dist/types/zeyos/connect.d.ts +6 -0
  40. package/dist/types/zeyos/form.d.ts +50 -9
  41. package/dist/types/zeyos/index.d.ts +1 -1
  42. package/dist/types/zeyos/query.d.ts +79 -0
  43. package/dist/types/zeyos/schema.d.ts +24 -1
  44. package/dist/types/zeyos/table.d.ts +100 -14
  45. package/dist/types/zeyos/view.d.ts +10 -0
  46. package/dist/zx-zeyos.esm.js +1621 -443
  47. package/dist/zx-zeyos.esm.min.js +53 -5
  48. package/dist/zx.css +1411 -660
  49. package/dist/zx.esm.js +1976 -703
  50. package/dist/zx.esm.min.js +56 -8
  51. package/dist/zx.global.js +1978 -705
  52. package/dist/zx.global.min.js +56 -8
  53. package/dist/zx.min.css +1 -1
  54. package/docs/api.json +219 -34
  55. package/docs/llms.md +472 -110
  56. package/docs/llms.txt +3 -3
  57. package/package.json +1 -1
  58. package/src/components/account-menu/account-menu.css +35 -10
  59. package/src/components/activity-item/activity-item.css +1 -1
  60. package/src/components/activity-item/activity-item.js +3 -0
  61. package/src/components/activity-list/activity-list.css +38 -3
  62. package/src/components/activity-list/activity-list.js +34 -0
  63. package/src/components/app-icon/app-icon.css +1 -1
  64. package/src/components/app-rail/app-rail.css +40 -79
  65. package/src/components/app-sidebar/app-sidebar.css +109 -174
  66. package/src/components/app-sidebar/app-sidebar.js +21 -6
  67. package/src/components/aurora/aurora.css +5 -3
  68. package/src/components/avatar/avatar.css +32 -7
  69. package/src/components/avatar/avatar.js +72 -8
  70. package/src/components/badge/badge.css +54 -2
  71. package/src/components/badge/badge.js +29 -2
  72. package/src/components/button/button.css +67 -9
  73. package/src/components/button/button.js +38 -0
  74. package/src/components/calendar/calendar.css +46 -46
  75. package/src/components/card/card.css +2 -2
  76. package/src/components/card-view/card-view.css +9 -7
  77. package/src/components/chart/chart.css +4 -1
  78. package/src/components/chart/chart.js +12 -0
  79. package/src/components/check-button/check-button.css +10 -2
  80. package/src/components/checklist/checklist.css +9 -2
  81. package/src/components/checklist/checklist.js +11 -5
  82. package/src/components/checklist/field-adapter.js +3 -1
  83. package/src/components/code-editor/code-editor.css +6 -5
  84. package/src/components/code-editor/code-editor.js +19 -1
  85. package/src/components/context-menu/context-menu.css +9 -4
  86. package/src/components/copy/copy-button.css +6 -0
  87. package/src/components/copy/copy-input.css +8 -3
  88. package/src/components/data-filter/data-filter.css +5 -4
  89. package/src/components/date-picker/date-picker.css +6 -6
  90. package/src/components/date-picker/date-range-picker.css +3 -3
  91. package/src/components/date-picker/time-picker.css +1 -1
  92. package/src/components/datebox/date-range-box.css +79 -2
  93. package/src/components/datebox/date-range-box.js +13 -2
  94. package/src/components/datebox/datebox.css +79 -2
  95. package/src/components/datebox/datebox.js +13 -2
  96. package/src/components/dialog/dialog.css +26 -0
  97. package/src/components/dialog/dialog.js +99 -17
  98. package/src/components/dropdown/dropdown.css +1 -1
  99. package/src/components/dropdown/dropdown.js +5 -3
  100. package/src/components/empty-state/empty-state.css +2 -2
  101. package/src/components/entity-ref/entity-ref.css +1 -1
  102. package/src/components/entity-ref/entity-ref.js +1 -1
  103. package/src/components/field/field.css +28 -9
  104. package/src/components/field/field.js +68 -26
  105. package/src/components/field-upload/field-upload.css +1 -1
  106. package/src/components/fieldset/fieldset.css +5 -3
  107. package/src/components/fieldset/fieldset.js +28 -5
  108. package/src/components/file-item/file-item.js +144 -10
  109. package/src/components/filter/filter.js +7 -3
  110. package/src/components/filter-panel/filter-panel.css +1 -1
  111. package/src/components/finder/finder.css +15 -13
  112. package/src/components/form/form.css +5 -4
  113. package/src/components/form/form.js +11 -2
  114. package/src/components/grid/grid.css +1 -1
  115. package/src/components/groupbox/groupbox.css +1 -1
  116. package/src/components/kanban-view/kanban-card.js +11 -3
  117. package/src/components/kanban-view/kanban-view.css +39 -15
  118. package/src/components/kanban-view/kanban-view.js +4 -4
  119. package/src/components/launcher/launcher.css +107 -25
  120. package/src/components/launcher/launcher.js +503 -41
  121. package/src/components/list-toolbar/list-toolbar.css +13 -8
  122. package/src/components/list-toolbar/list-toolbar.js +1 -1
  123. package/src/components/master-panel/master-panel.css +2 -0
  124. package/src/components/menu-button/menu-button.css +9 -4
  125. package/src/components/menu-button/menu-button.js +65 -9
  126. package/src/components/message/message.css +33 -3
  127. package/src/components/message/message.js +217 -15
  128. package/src/components/modal/modal.js +18 -3
  129. package/src/components/navigation-bar/navigation-bar.css +18 -9
  130. package/src/components/number-field/field-adapter.js +1 -0
  131. package/src/components/number-field/number-field.css +70 -0
  132. package/src/components/number-field/number-field.js +2 -0
  133. package/src/components/pagination/pagination.css +8 -6
  134. package/src/components/panel/panel.css +1 -1
  135. package/src/components/rating/rating.css +7 -0
  136. package/src/components/select/color.js +22 -0
  137. package/src/components/select/field-adapter.js +2 -0
  138. package/src/components/select/glyphs.js +165 -0
  139. package/src/components/select/select.css +138 -45
  140. package/src/components/select/select.js +83 -47
  141. package/src/components/sheet/sheet.css +11 -8
  142. package/src/components/slider/slider.css +16 -2
  143. package/src/components/slider/slider.js +1 -1
  144. package/src/components/sort-control/sort-control.css +4 -1
  145. package/src/components/stat-tile/stat-tile.css +41 -46
  146. package/src/components/stat-tile/stat-tile.js +17 -6
  147. package/src/components/tabbox/tabbox.css +32 -27
  148. package/src/components/tabbox/tabbox.js +5 -1
  149. package/src/components/table/table.css +104 -29
  150. package/src/components/table/table.js +90 -16
  151. package/src/components/tag-picker/tag-picker.css +83 -5
  152. package/src/components/tag-picker/tag-picker.js +25 -8
  153. package/src/components/timebox/timebox.css +22 -2
  154. package/src/components/toggle/toggle.css +12 -1
  155. package/src/components/toolbar/toolbar.js +2 -2
  156. package/src/components/tooltip/tooltip.css +4 -0
  157. package/src/components/tooltip/tooltip.js +10 -3
  158. package/src/components/tree/tree.css +25 -16
  159. package/src/components/tree/tree.js +31 -0
  160. package/src/components/value-list/value-list.css +3 -3
  161. package/src/components/view/record-view.js +1 -1
  162. package/src/core/breakpoint.js +1 -1
  163. package/src/core/icons.js +193 -60
  164. package/src/core/motion.js +147 -0
  165. package/src/core/overlay-host.js +21 -2
  166. package/src/index.js +5 -3
  167. package/src/internal/app-rail.js +1 -2
  168. package/src/zeyos/connect.js +6 -2
  169. package/src/zeyos/form.js +67 -27
  170. package/src/zeyos/index.js +1 -1
  171. package/src/zeyos/query.js +153 -0
  172. package/src/zeyos/schema.js +144 -27
  173. package/src/zeyos/table.js +68 -5
  174. package/src/zeyos/view.js +6 -0
  175. package/styles/base.css +56 -0
  176. package/styles/icons.css +11 -1
  177. package/styles/tokens/dark.css +113 -37
  178. package/styles/tokens/global.css +121 -27
  179. package/styles/tokens/semantic.css +100 -31
  180. package/styles/tokens/themes.css +7 -6
  181. package/styles/zx.css +0 -1
  182. 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,6 +393,21 @@ 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',
@@ -346,8 +418,31 @@ export class Message extends Component {
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,6 +512,27 @@ 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);
526
+ }
527
+
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();
418
536
  }
419
537
 
420
538
  /**
@@ -428,11 +546,23 @@ export class Message extends Component {
428
546
  return message === key ? fallback : message;
429
547
  }
430
548
 
431
- /** @param {Element|string|null|undefined} scope @returns {Message} */
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
+ */
432
556
  static #floatingRegion(scope) {
433
- 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);
434
560
  const existing = this.#floating.get(host);
435
- 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
+ }
436
566
  const region = h('div', {
437
567
  class: 'zx-message-region',
438
568
  popover: 'manual',
@@ -441,10 +571,18 @@ export class Message extends Component {
441
571
  });
442
572
  host.append(region);
443
573
  const floating = new Message(region);
574
+ floating._floatingHost = host;
444
575
  this.#floating.set(host, floating);
445
576
  if (typeof region.showPopover === 'function') region.showPopover();
446
577
  return floating;
447
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
+ }
448
586
  }
449
587
 
450
588
  /** @param {unknown} value @returns {MessageKind} */
@@ -460,6 +598,70 @@ function normalizeTimeout(value) {
460
598
  return Number.isFinite(timeout) ? Math.max(0, timeout) : 4000;
461
599
  }
462
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
+
463
665
  /** @param {unknown} value @returns {number} */
464
666
  function normalizeLimit(value) {
465
667
  const limit = Number(value);
@@ -1,6 +1,6 @@
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
4
 
5
5
  /**
6
6
  * Controls initial focus may be placed on: what a reader reaches with Tab, rather than everything
@@ -18,7 +18,7 @@ const FOCUSABLE_SELECTOR = [
18
18
  * @property {boolean} [closable=true] Whether Escape may close the modal.
19
19
  * @property {boolean} [lightDismiss=false] Whether a backdrop click closes the modal.
20
20
  * @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.
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.
22
22
  * @property {(event: CustomEvent<Record<string, never>>) => void} [onopen] Open event listener.
23
23
  * @property {(event: CustomEvent<{result: unknown}>) => void} [onclose] Close event listener.
24
24
  * @property {(event: CustomEvent<Record<string, never>>) => void} [oncancel] Cancel event listener.
@@ -266,10 +266,25 @@ export class Modal extends Component {
266
266
  class: 'zx-modal'
267
267
  }, h('div', { class: 'zx-modal__content', ref: 'content' })));
268
268
  dialog.dataset.state = 'closed';
269
- this.mountTarget().append(dialog);
269
+ this._mountInitial(dialog);
270
270
  return dialog;
271
271
  }
272
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
+
273
288
  /**
274
289
  * Opens the modal in the browser top layer.
275
290
  * @returns {this}
@@ -29,7 +29,8 @@
29
29
  padding-inline: var(--zx-space-2);
30
30
  color: var(--zx-color-accent);
31
31
  font-size: var(--zx-text-xl);
32
- font-weight: 750;
32
+ font-weight: 650;
33
+ letter-spacing: var(--zx-tracking-title);
33
34
  }
34
35
 
35
36
  .zx-navigation-bar__navigation {
@@ -51,10 +52,11 @@
51
52
  gap: var(--zx-space-2);
52
53
  border: 0;
53
54
  border-radius: var(--zx-control-radius);
54
- background: var(--zx-color-bg-raised);
55
+ background: transparent;
55
56
  padding-inline: var(--zx-control-pad-x);
56
57
  color: var(--zx-color-text-muted);
57
58
  font: inherit;
59
+ font-weight: var(--zx-weight-medium);
58
60
  text-decoration: none;
59
61
  cursor: pointer;
60
62
  }
@@ -64,11 +66,12 @@
64
66
  color: var(--zx-color-text);
65
67
  }
66
68
 
69
+ /* Where you are, not a pick: the neutral current fill, the full text colour, a heavier label. */
67
70
  .zx-navigation-bar__item[aria-current="page"],
68
71
  .zx-navigation-bar__item[aria-selected="true"] {
69
- background: var(--zx-color-bg-selected);
70
- color: var(--zx-color-accent);
71
- font-weight: 650;
72
+ background: var(--zx-color-bg-current);
73
+ color: var(--zx-color-text);
74
+ font-weight: var(--zx-weight-semibold);
72
75
  }
73
76
 
74
77
  .zx-navigation-bar__item[aria-disabled="true"] {
@@ -81,18 +84,24 @@
81
84
  box-shadow: var(--zx-focus-ring);
82
85
  }
83
86
 
87
+ /* A count beside a label, not a call to action: neutral, like the Tabbox count. */
84
88
  .zx-navigation-bar__badge {
85
89
  min-inline-size: var(--zx-space-4);
86
90
  border-radius: var(--zx-radius-full);
87
- background: var(--zx-color-accent);
91
+ background: var(--zx-color-bg-current);
88
92
  padding-inline: var(--zx-space-1);
89
- color: var(--zx-color-on-accent);
90
- font-size: var(--zx-text-sm);
91
- font-weight: 700;
93
+ color: var(--zx-color-text-muted);
94
+ font-size: var(--zx-text-xs);
95
+ font-variant-numeric: tabular-nums;
96
+ font-weight: var(--zx-weight-medium);
92
97
  line-height: var(--zx-space-4);
93
98
  text-align: center;
94
99
  }
95
100
 
101
+ .zx-navigation-bar__item:is([aria-current="page"], [aria-selected="true"]) .zx-navigation-bar__badge {
102
+ color: var(--zx-color-text);
103
+ }
104
+
96
105
  .zx-navigation-bar__more {
97
106
  display: none;
98
107
  }
@@ -11,6 +11,7 @@ export function registerNumberFieldAdapter() {
11
11
  label: options.label ? String(options.label) : undefined,
12
12
  placeholder: options.placeholder,
13
13
  required: options.required,
14
+ name: field.name || null,
14
15
  ...(options.props ?? {})
15
16
  }));
16
17
  let setting = false;
@@ -75,3 +75,73 @@
75
75
  border-end-end-radius: var(--zx-control-radius);
76
76
  border-inline-start: 1px solid var(--zx-color-border-control);
77
77
  }
78
+
79
+ /* In a Field the control fills its column, as a text input does, and gives way in a narrow one. */
80
+ .zx-field__control > .zx-number-field {
81
+ display: flex;
82
+ inline-size: 100%;
83
+ }
84
+
85
+ .zx-field__control > .zx-number-field .zx-number-field__input {
86
+ min-inline-size: 0;
87
+ }
88
+
89
+ /*
90
+ * Quiet fields. Inside `[data-zx-fields="quiet"]` the number reads as text: no border, no fill,
91
+ * and the value at the start, where the text of the quiet fields around it starts. The steppers
92
+ * move after it, both at the trailing end, and stay hidden until hover or focus — kept in the
93
+ * layout, so nothing moves when they return. Focus brings back the border, the control fill and
94
+ * the focus ring. Disabled and read-only fields never light up.
95
+ */
96
+ [data-zx-fields="quiet"] .zx-number-field {
97
+ border-color: transparent;
98
+ background: transparent;
99
+ }
100
+
101
+ [data-zx-fields="quiet"] .zx-number-field__input {
102
+ padding-inline-start: var(--zx-control-pad-x);
103
+ text-align: start;
104
+ }
105
+
106
+ [data-zx-fields="quiet"] .zx-number-field__step {
107
+ border-color: transparent;
108
+ opacity: 0;
109
+ }
110
+
111
+ [data-zx-fields="quiet"] .zx-number-field__step[data-step="down"] {
112
+ order: 1;
113
+ border-inline-end: 0;
114
+ border-inline-start: 1px solid transparent;
115
+ border-radius: 0;
116
+ }
117
+
118
+ [data-zx-fields="quiet"] .zx-number-field__step[data-step="up"] {
119
+ order: 2;
120
+ }
121
+
122
+ [data-zx-fields="quiet"] .zx-number-field:not([data-disabled], [data-readonly]):hover {
123
+ background: var(--zx-color-bg-hover);
124
+ }
125
+
126
+ [data-zx-fields="quiet"] .zx-number-field:not([data-disabled], [data-readonly]):is(:hover, :focus-within) .zx-number-field__step {
127
+ opacity: 1;
128
+ }
129
+
130
+ [data-zx-fields="quiet"] .zx-number-field:not([data-disabled], [data-readonly]):focus-within {
131
+ border-color: var(--zx-color-border-control);
132
+ background: var(--zx-color-bg-control);
133
+ }
134
+
135
+ [data-zx-fields="quiet"] .zx-number-field:not([data-disabled], [data-readonly]):focus-within .zx-number-field__step {
136
+ border-color: var(--zx-color-border-control);
137
+ }
138
+
139
+ @media (prefers-reduced-motion: no-preference) {
140
+ [data-zx-fields="quiet"] .zx-number-field {
141
+ transition: background-color var(--zx-dur-fast) var(--zx-ease), border-color var(--zx-dur-fast) var(--zx-ease);
142
+ }
143
+
144
+ [data-zx-fields="quiet"] .zx-number-field__step {
145
+ transition: opacity var(--zx-dur-fast) var(--zx-ease), border-color var(--zx-dur-fast) var(--zx-ease);
146
+ }
147
+ }
@@ -336,6 +336,8 @@ export class NumberField extends Component {
336
336
  /** @param {KeyboardEvent} event @returns {void} */
337
337
  _onKeydown(event) {
338
338
  if (this._disabled || this._readonly) return;
339
+ // Chords belong to the page — Mod+Enter submits the form or dialog the field is in.
340
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
339
341
  const large = Math.max(1, Math.trunc(Number(this.options.largeStep) || 1));
340
342
  const actions = {
341
343
  ArrowUp: () => this._stepBy(1),
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * One row that wraps: the summary is pushed to the start, the page-size select and the pager sit
5
5
  * at the end. Page buttons are ordinary `.zx-btn` controls so they inherit the library's control
6
- * height, radius, and focus ring; only the current page repaints in the accent colour.
6
+ * height, radius, and focus ring; the current page takes the neutral current fill — it is where
7
+ * you are, not a call to action.
7
8
  */
8
9
 
9
10
  .zx-pagination {
@@ -70,15 +71,16 @@
70
71
  font-variant-numeric: tabular-nums;
71
72
  }
72
73
 
74
+ /* The page you are on: the neutral current fill and a heavier figure, not a solid accent. */
73
75
  .zx-pagination__page[aria-current="page"] {
74
- border-color: var(--zx-color-accent);
75
- background: var(--zx-color-accent);
76
- color: var(--zx-color-on-accent);
76
+ border-color: transparent;
77
+ background: var(--zx-color-bg-current);
78
+ color: var(--zx-color-text);
79
+ font-weight: var(--zx-weight-semibold);
77
80
  }
78
81
 
79
82
  .zx-pagination__page[aria-current="page"]:hover:not(:disabled) {
80
- border-color: var(--zx-color-accent-hover);
81
- background: var(--zx-color-accent-hover);
83
+ background: var(--zx-color-bg-current);
82
84
  }
83
85
 
84
86
  /* Decorative gap marker: never focusable, never announced. */
@@ -35,7 +35,7 @@
35
35
  padding: var(--zx-space-3) var(--zx-space-4);
36
36
  color: var(--zx-color-text);
37
37
  font: inherit;
38
- font-weight: 650;
38
+ font-weight: var(--zx-weight-semibold);
39
39
  text-align: start;
40
40
  }
41
41
 
@@ -30,6 +30,13 @@
30
30
  clip-path: inset(0 calc(100% - var(--zx-rating-fill)) 0 0);
31
31
  }
32
32
 
33
+ /* The icons are outlines; a chosen star is a solid one, so the two states differ in shape as well
34
+ as in colour. */
35
+ .zx-rating__glyph[data-part="on"],
36
+ .zx-rating__glyph[data-part="on"] .zx-icon {
37
+ fill: currentColor;
38
+ }
39
+
33
40
  .zx-rating__symbol[data-preview] .zx-rating__glyph[data-part="on"] {
34
41
  color: var(--zx-color-warning);
35
42
  opacity: 0.75;
@@ -0,0 +1,22 @@
1
+ import { isCssColor } from '../../core/util.js';
2
+
3
+ /** A reference to one custom property, such as `var(--zx-color-tone-blue-fg)`. */
4
+ const TOKEN_REFERENCE = /^var\(\s*--[\w-]+\s*\)$/;
5
+
6
+ /**
7
+ * Accepts an item colour for an inline custom property: a CSS colour literal (`#0b7285`, `rgb(…)`,
8
+ * `oklch(…)`, a named colour) or a reference to a design token (`var(--zx-color-tone-blue-fg)`),
9
+ * so an item can follow the theme rather than fix one colour for light and dark alike. Anything
10
+ * else — other functions, declarations, a `var()` with a fallback — is refused.
11
+ *
12
+ * Shared by `Select.entity()` and `TagPicker`, the two components that take a colour per item.
13
+ *
14
+ * @param {unknown} value Candidate colour.
15
+ * @returns {string|null} The trimmed colour, or null when the value is not one.
16
+ */
17
+ export function itemColor(value) {
18
+ if (typeof value !== 'string') return null;
19
+ const text = value.trim();
20
+ if (TOKEN_REFERENCE.test(text)) return text;
21
+ return isCssColor(text) ? text : null;
22
+ }
@@ -14,6 +14,8 @@ export function registerSelectFieldAdapter() {
14
14
  if (event.target.closest?.('.zx-field__label')) field.adapter.focus();
15
15
  });
16
16
  delete selectOptions.preset;
17
+ // The priority preset offers "No priority" (null) by default; a required field cannot hold it.
18
+ if (preset === 'priority' && props.clearable === undefined) selectOptions.clearable = !options.required;
17
19
  const select = field.own(preset === 'priority'
18
20
  ? Select.priority(null, selectOptions)
19
21
  : preset === 'status'