@zeyos/zx 4.3.0 → 4.4.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 (136) hide show
  1. package/CHANGELOG.md +266 -1
  2. package/README.md +7 -3
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +195 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
  6. package/dist/types/components/button/button.d.ts +8 -0
  7. package/dist/types/components/calendar/calendar-model.d.ts +22 -0
  8. package/dist/types/components/calendar/calendar.d.ts +19 -0
  9. package/dist/types/components/card/card.d.ts +7 -0
  10. package/dist/types/components/card-view/card-view.d.ts +12 -4
  11. package/dist/types/components/card-view/record-card.d.ts +53 -5
  12. package/dist/types/components/code-editor/code-editor.d.ts +160 -0
  13. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  14. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  15. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  16. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  17. package/dist/types/components/dialog/dialog.d.ts +29 -0
  18. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  19. package/dist/types/components/file-item/file-item.d.ts +234 -0
  20. package/dist/types/components/file-list/file-list.d.ts +171 -0
  21. package/dist/types/components/filter/filter.d.ts +91 -36
  22. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  23. package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
  24. package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
  25. package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
  26. package/dist/types/components/launcher/launcher.d.ts +19 -12
  27. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  28. package/dist/types/components/menu-button/menu-button.d.ts +4 -37
  29. package/dist/types/components/message/message.d.ts +7 -0
  30. package/dist/types/components/modal/modal.d.ts +48 -0
  31. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  32. package/dist/types/components/panel/panel.d.ts +7 -0
  33. package/dist/types/components/search/search.d.ts +7 -0
  34. package/dist/types/components/select/select.d.ts +4 -0
  35. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  36. package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
  37. package/dist/types/components/tabbox/tabbox.d.ts +7 -0
  38. package/dist/types/components/table/table.d.ts +144 -0
  39. package/dist/types/components/table-view/table-view.d.ts +11 -5
  40. package/dist/types/components/timebox/timebox.d.ts +7 -0
  41. package/dist/types/components/value-list/value-list.d.ts +8 -0
  42. package/dist/types/components/view/record-view.d.ts +96 -5
  43. package/dist/types/core/component.d.ts +6 -1
  44. package/dist/types/core/icons.d.ts +17 -0
  45. package/dist/types/index.d.ts +13 -1
  46. package/dist/types/internal/app-rail.d.ts +10 -0
  47. package/dist/types/internal/menu-items.d.ts +134 -0
  48. package/dist/zx-zeyos.esm.js +651 -88
  49. package/dist/zx-zeyos.esm.min.js +8 -1
  50. package/dist/zx.css +6266 -3900
  51. package/dist/zx.esm.js +8487 -1805
  52. package/dist/zx.esm.min.js +10 -3
  53. package/dist/zx.global.js +8487 -1805
  54. package/dist/zx.global.min.js +10 -3
  55. package/dist/zx.min.css +1 -1
  56. package/docs/api.json +1811 -247
  57. package/docs/llms.md +345 -40
  58. package/docs/llms.txt +11 -7
  59. package/package.json +1 -2
  60. package/src/components/activity-item/activity-item.css +137 -0
  61. package/src/components/activity-item/activity-item.js +599 -0
  62. package/src/components/activity-list/activity-list.css +64 -0
  63. package/src/components/activity-list/activity-list.js +375 -0
  64. package/src/components/app-rail/app-rail.css +227 -19
  65. package/src/components/app-sidebar/app-sidebar.css +414 -24
  66. package/src/components/app-sidebar/app-sidebar.js +65 -5
  67. package/src/components/button/button.css +8 -0
  68. package/src/components/button/button.js +6 -1
  69. package/src/components/calendar/calendar-model.js +45 -0
  70. package/src/components/calendar/calendar.css +30 -10
  71. package/src/components/calendar/calendar.js +41 -13
  72. package/src/components/card/card.js +12 -1
  73. package/src/components/card-view/card-view.css +212 -9
  74. package/src/components/card-view/card-view.js +32 -16
  75. package/src/components/card-view/record-card.js +98 -21
  76. package/src/components/code-editor/code-editor.css +67 -0
  77. package/src/components/code-editor/code-editor.js +288 -0
  78. package/src/components/code-editor/field-adapter.js +34 -0
  79. package/src/components/context-menu/context-menu.css +80 -3
  80. package/src/components/context-menu/context-menu.js +22 -30
  81. package/src/components/date-picker/date-picker.js +22 -8
  82. package/src/components/date-picker/month-picker.js +3 -3
  83. package/src/components/date-picker/time-picker.js +20 -8
  84. package/src/components/dialog/dialog.js +88 -66
  85. package/src/components/dropdown/dropdown.js +2 -2
  86. package/src/components/entity-ref/entity-ref.css +182 -0
  87. package/src/components/entity-ref/entity-ref.js +496 -0
  88. package/src/components/file-item/file-item.css +88 -0
  89. package/src/components/file-item/file-item.js +371 -0
  90. package/src/components/file-list/file-list.css +51 -0
  91. package/src/components/file-list/file-list.js +320 -0
  92. package/src/components/filter/filter.css +27 -1
  93. package/src/components/filter/filter.js +199 -45
  94. package/src/components/filter-panel/filter-panel.css +104 -0
  95. package/src/components/filter-panel/filter-panel.js +964 -0
  96. package/src/components/kanban-view/kanban-card.js +239 -0
  97. package/src/components/kanban-view/kanban-policy.js +412 -0
  98. package/src/components/kanban-view/kanban-view.css +674 -36
  99. package/src/components/kanban-view/kanban-view.js +1762 -240
  100. package/src/components/launcher/launcher.js +59 -27
  101. package/src/components/list-toolbar/list-toolbar.css +189 -0
  102. package/src/components/list-toolbar/list-toolbar.js +851 -0
  103. package/src/components/menu-button/menu-button.css +80 -3
  104. package/src/components/menu-button/menu-button.js +21 -37
  105. package/src/components/message/message.css +14 -12
  106. package/src/components/message/message.js +13 -2
  107. package/src/components/modal/modal.css +15 -0
  108. package/src/components/modal/modal.js +141 -0
  109. package/src/components/navigation-bar/navigation-bar.css +77 -2
  110. package/src/components/navigation-bar/navigation-bar.js +171 -4
  111. package/src/components/panel/panel.js +13 -2
  112. package/src/components/search/search.js +17 -5
  113. package/src/components/select/field-adapter.js +4 -1
  114. package/src/components/select/select.js +3 -0
  115. package/src/components/sort-control/sort-control.css +17 -0
  116. package/src/components/sort-control/sort-control.js +140 -0
  117. package/src/components/stat-tile/stat-tile.css +213 -0
  118. package/src/components/stat-tile/stat-tile.js +590 -0
  119. package/src/components/tabbox/tabbox.js +12 -1
  120. package/src/components/table/table.css +51 -2
  121. package/src/components/table/table.js +298 -19
  122. package/src/components/table-view/table-view.css +8 -2
  123. package/src/components/table-view/table-view.js +21 -6
  124. package/src/components/tag-picker/tag-picker.css +3 -1
  125. package/src/components/timebox/timebox.js +18 -5
  126. package/src/components/value-list/value-list.js +22 -6
  127. package/src/components/view/record-view.css +5 -0
  128. package/src/components/view/record-view.js +100 -7
  129. package/src/core/component.js +8 -3
  130. package/src/core/icons.js +74 -5
  131. package/src/index.js +33 -3
  132. package/src/internal/app-rail.js +19 -1
  133. package/src/internal/menu-items.js +142 -0
  134. package/styles/print.css +192 -0
  135. package/styles/zx.css +11 -0
  136. package/DESIGN-SYSTEM.md +0 -182
@@ -11,15 +11,19 @@
11
11
  }
12
12
 
13
13
  .zx-menu-button__item {
14
- display: flex;
14
+ display: grid;
15
+ grid-template-columns: auto minmax(0, 1fr) auto;
15
16
  align-items: center;
16
17
  gap: var(--zx-space-2);
17
18
  inline-size: 100%;
18
19
  min-block-size: var(--zx-control-height);
19
- border-color: transparent;
20
+ border: 1px solid transparent;
20
21
  background: transparent;
21
22
  padding-inline: var(--zx-control-pad-x);
23
+ color: inherit;
24
+ font: inherit;
22
25
  text-align: start;
26
+ text-decoration: none;
23
27
  }
24
28
 
25
29
  .zx-menu-button__item:hover,
@@ -37,11 +41,84 @@
37
41
  opacity: .65;
38
42
  }
39
43
 
40
- .zx-menu-button__icon {
44
+ .zx-menu-button__leading,
45
+ .zx-menu-button__meta {
41
46
  display: inline-flex;
47
+ align-items: center;
48
+ gap: var(--zx-space-2);
42
49
  flex: none;
43
50
  }
44
51
 
52
+ .zx-menu-button__meta {
53
+ justify-content: flex-end;
54
+ }
55
+
56
+ .zx-menu-button__leading {
57
+ justify-content: center;
58
+ inline-size: var(--zx-space-4);
59
+ }
60
+
61
+ .zx-menu-button__body {
62
+ display: flex;
63
+ min-inline-size: 0;
64
+ flex-direction: column;
65
+ }
66
+
67
+ .zx-menu-button__label,
68
+ .zx-menu-button__description {
69
+ overflow: hidden;
70
+ text-overflow: ellipsis;
71
+ white-space: nowrap;
72
+ }
73
+
74
+ .zx-menu-button__description,
75
+ .zx-menu-button__shortcut,
76
+ .zx-menu-button__adornment {
77
+ color: var(--zx-color-text-muted);
78
+ font-size: var(--zx-text-xs);
79
+ }
80
+
81
+ .zx-menu-button__badge {
82
+ min-inline-size: var(--zx-space-4);
83
+ border-radius: var(--zx-radius-full);
84
+ background: var(--zx-color-bg-muted);
85
+ padding-inline: var(--zx-space-1);
86
+ color: var(--zx-color-text-muted);
87
+ font-size: var(--zx-text-xs);
88
+ text-align: center;
89
+ }
90
+
91
+ .zx-menu-button__shortcut {
92
+ border: 0;
93
+ background: transparent;
94
+ padding: 0;
95
+ font-family: var(--zx-font-mono);
96
+ }
97
+
98
+ .zx-menu-button__check {
99
+ inline-size: var(--zx-space-3);
100
+ block-size: var(--zx-space-3);
101
+ border: 1px solid var(--zx-color-border-strong);
102
+ border-radius: var(--zx-radius-sm);
103
+ }
104
+
105
+ .zx-menu-button__check[data-check-kind="radio"] {
106
+ border-radius: var(--zx-radius-full);
107
+ }
108
+
109
+ .zx-menu-button__item[aria-checked="true"] .zx-menu-button__check {
110
+ border-color: var(--zx-color-accent);
111
+ background: var(--zx-color-accent);
112
+ box-shadow: inset 0 0 0 2px var(--zx-color-bg-raised);
113
+ }
114
+
115
+ .zx-menu-button__heading {
116
+ padding: var(--zx-space-2) var(--zx-control-pad-x) var(--zx-space-1);
117
+ color: var(--zx-color-text-muted);
118
+ font-size: var(--zx-text-xs);
119
+ font-weight: 600;
120
+ }
121
+
45
122
  .zx-menu-button__separator {
46
123
  block-size: 1px;
47
124
  margin: var(--zx-space-1) var(--zx-space-2);
@@ -2,20 +2,15 @@ import { Component } from '../../core/component.js';
2
2
  import { h, resolveElement } from '../../core/dom.js';
3
3
  import { icon } from '../../core/icons.js';
4
4
  import { rovingTabindex, typeahead } from '../../core/keyboard.js';
5
+ import {
6
+ isMenuItem,
7
+ MENU_ITEM_SELECTOR,
8
+ renderMenuEntries
9
+ } from '../../internal/menu-items.js';
5
10
  import { Dropdown } from '../dropdown/dropdown.js';
6
11
 
7
- const MENU_ITEM_SELECTOR = '[role="menuitem"]';
8
-
9
- /**
10
- * @typedef {Object} MenuItem
11
- * @property {string} label Item label.
12
- * @property {string} [icon] Kernel icon name.
13
- * @property {unknown} [value] Value emitted on selection.
14
- * @property {boolean} [disabled=false] Whether selection is disabled.
15
- * @property {boolean} [danger=false] Whether the action is dangerous.
16
- * @property {(value: unknown, item: MenuItem, menuButton: MenuButton) => void} [onselect] Selection callback.
17
- */
18
- /** @typedef {MenuItem|'-'} MenuButtonItem */
12
+ /** @typedef {import('../../internal/menu-items.js').MenuItem} MenuItem */
13
+ /** @typedef {import('../../internal/menu-items.js').MenuEntry} MenuButtonItem */
19
14
  /**
20
15
  * @typedef {Object} MenuButtonOptions
21
16
  * @property {string} [label=''] Trigger label.
@@ -116,7 +111,7 @@ export class MenuButton extends Component {
116
111
  this.listen(this.el, 'keydown', (event) => this.#onTriggerKeydown(event));
117
112
  this.listen(this.#panel, 'click', (event) => {
118
113
  const item = event.target.closest?.(MENU_ITEM_SELECTOR);
119
- if (item && this.#panel.contains(item)) this.#selectElement(item);
114
+ if (item && this.#panel.contains(item)) this.#selectElement(item, event);
120
115
  });
121
116
  this.listen(this.#panel, 'keydown', (event) => this.#onMenuKeydown(event));
122
117
  }
@@ -142,25 +137,7 @@ export class MenuButton extends Component {
142
137
  */
143
138
  setItems(items) {
144
139
  this.#items = Array.isArray(items) ? items.slice() : [];
145
- this.#panel.replaceChildren();
146
- this.#items.forEach((item, index) => {
147
- if (item === '-') {
148
- this.#panel.append(h('div', { class: 'zx-menu-button__separator', role: 'separator' }));
149
- return;
150
- }
151
- const children = [];
152
- if (item.icon) children.push(h('span', { class: 'zx-menu-button__icon' }, icon(item.icon)));
153
- children.push(h('span', { class: 'zx-menu-button__label' }, item.label));
154
- this.#panel.append(h('button', {
155
- class: 'zx-menu-button__item',
156
- type: 'button',
157
- role: 'menuitem',
158
- tabindex: '-1',
159
- 'data-menu-item': String(index),
160
- 'data-danger': item.danger ? 'true' : null,
161
- ariaDisabled: item.disabled ? 'true' : null
162
- }, children));
163
- });
140
+ this.#panel.replaceChildren(...renderMenuEntries(this.#items, 'menu-button'));
164
141
  return this;
165
142
  }
166
143
 
@@ -265,20 +242,27 @@ export class MenuButton extends Component {
265
242
  if (event.key === 'Enter' || event.key === ' ') {
266
243
  const item = event.target.closest?.(MENU_ITEM_SELECTOR);
267
244
  if (!item || !this.#panel.contains(item)) return;
245
+ if (event.key === 'Enter' && item.tagName === 'A') return;
268
246
  event.preventDefault();
269
- this.#selectElement(item);
247
+ /** @type {HTMLElement} */ (item).click();
270
248
  return;
271
249
  }
272
250
  this.#typeahead(event);
273
251
  }
274
252
 
275
- /** @param {Element} element @returns {void} */
276
- #selectElement(element) {
253
+ /** @param {Element} element @param {Event|null} [event=null] @returns {void} */
254
+ #selectElement(element, event = null) {
277
255
  const index = Number(element.getAttribute('data-menu-item'));
278
256
  const item = this.#items[index];
279
- if (!item || item === '-' || item.disabled) return;
257
+ if (!isMenuItem(item) || item.disabled) {
258
+ event?.preventDefault();
259
+ return;
260
+ }
280
261
  const selected = this.emit('select', { value: item.value, item });
281
- if (selected.defaultPrevented) return;
262
+ if (selected.defaultPrevented) {
263
+ event?.preventDefault();
264
+ return;
265
+ }
282
266
  try {
283
267
  item.onselect?.(item.value, item, this);
284
268
  } finally {
@@ -27,14 +27,15 @@
27
27
 
28
28
  .zx-message__toast {
29
29
  --zx-message-kind: var(--zx-color-info);
30
+ --zx-message-fallback-bg: var(--zx-color-info-bg);
30
31
  position: relative;
31
32
  display: grid;
32
33
  grid-template-columns: auto minmax(0, 1fr) auto;
33
34
  align-items: start;
34
35
  gap: var(--zx-space-3);
35
36
  border: 1px solid var(--zx-color-overlay-border);
36
- border-radius: var(--zx-radius-md);
37
- background: var(--zx-color-info-bg);
37
+ border-radius: var(--zx-radius-lg);
38
+ background: var(--zx-message-fallback-bg);
38
39
  box-shadow: inset 0 0 0 1px var(--zx-message-kind), var(--zx-glass-raised-shadow);
39
40
  padding: var(--zx-space-3);
40
41
  color: var(--zx-color-text);
@@ -42,17 +43,17 @@
42
43
 
43
44
  .zx-message__toast[data-kind="success"] {
44
45
  --zx-message-kind: var(--zx-color-success);
45
- background: var(--zx-color-success-bg);
46
+ --zx-message-fallback-bg: var(--zx-color-success-bg);
46
47
  }
47
48
 
48
49
  .zx-message__toast[data-kind="warning"] {
49
50
  --zx-message-kind: var(--zx-color-warning);
50
- background: var(--zx-color-warning-bg);
51
+ --zx-message-fallback-bg: var(--zx-color-warning-bg);
51
52
  }
52
53
 
53
54
  .zx-message__toast[data-kind="error"] {
54
55
  --zx-message-kind: var(--zx-color-danger);
55
- background: var(--zx-color-danger-bg);
56
+ --zx-message-fallback-bg: var(--zx-color-danger-bg);
56
57
  }
57
58
 
58
59
  @supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
@@ -63,21 +64,19 @@
63
64
  background-clip: padding-box;
64
65
  -webkit-backdrop-filter: var(--zx-glass-filter-overlay);
65
66
  backdrop-filter: var(--zx-glass-filter-overlay);
66
- box-shadow: var(--zx-glass-overlay-shadow);
67
+ box-shadow: inset 3px 0 0 color-mix(in srgb, var(--zx-message-kind) 72%,
68
+ var(--zx-color-glass-highlight-strong)), var(--zx-glass-overlay-shadow);
67
69
  }
68
70
  }
69
71
 
70
72
  @media (prefers-reduced-transparency: reduce) {
71
73
  .zx-message__toast {
72
74
  border-color: var(--zx-color-border);
75
+ background: var(--zx-message-fallback-bg);
73
76
  -webkit-backdrop-filter: none;
74
77
  backdrop-filter: none;
75
78
  box-shadow: var(--zx-overlay-shadow);
76
79
  }
77
- .zx-message__toast[data-kind="info"] { background: var(--zx-color-info-bg); }
78
- .zx-message__toast[data-kind="success"] { background: var(--zx-color-success-bg); }
79
- .zx-message__toast[data-kind="warning"] { background: var(--zx-color-warning-bg); }
80
- .zx-message__toast[data-kind="error"] { background: var(--zx-color-danger-bg); }
81
80
  }
82
81
 
83
82
  .zx-message__icon {
@@ -92,9 +91,12 @@
92
91
  }
93
92
 
94
93
  /* Visual chrome comes from the shared .zx-icon-btn utility; only alignment is set here so
95
- the close control sits flush with the top-right corner without stretching the toast. */
94
+ the close control sits flush with the trailing top corner without stretching the toast.
95
+ Trailing, not right: in an RTL document the close control belongs on the left, and a
96
+ four-value `margin` shorthand is the one place a logical property cannot be spelled. */
96
97
  .zx-message__close {
97
- margin: calc(-1 * var(--zx-space-1)) calc(-1 * var(--zx-space-1)) 0 0;
98
+ margin-block: calc(-1 * var(--zx-space-1)) 0;
99
+ margin-inline: 0 calc(-1 * var(--zx-space-1));
98
100
  }
99
101
 
100
102
  .zx-message__progress {
@@ -340,8 +340,8 @@ export class Message extends Component {
340
340
  const close = h('button', {
341
341
  class: 'zx-icon-btn zx-message__close',
342
342
  type: 'button',
343
- ariaLabel: 'Close message',
344
- title: 'Close'
343
+ ariaLabel: this._message('message.close', 'Close message'),
344
+ title: this._message('message.closeTitle', 'Close')
345
345
  }, icon('x', { size: 14 }));
346
346
  node.append(close);
347
347
  this.listen(close, 'click', () => this._close(entry));
@@ -417,6 +417,17 @@ export class Message extends Component {
417
417
  this._queue.remove(entry);
418
418
  }
419
419
 
420
+ /**
421
+ * Resolves a message through the host translator, falling back to the built-in English text.
422
+ * @param {string} key Message key.
423
+ * @param {string} fallback English default.
424
+ * @returns {string}
425
+ */
426
+ _message(key, fallback) {
427
+ const message = this.msg(key);
428
+ return message === key ? fallback : message;
429
+ }
430
+
420
431
  /** @param {Element|string|null|undefined} scope @returns {Message} */
421
432
  static #floatingRegion(scope) {
422
433
  const host = overlayHost(scope ?? null);
@@ -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.
@@ -2,6 +2,15 @@ import { Component } from '../../core/component.js';
2
2
  import { h } from '../../core/dom.js';
3
3
  import { overlayHost } from '../../core/overlay-host.js';
4
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(',');
13
+
5
14
  /**
6
15
  * @typedef {Object} ModalOptions
7
16
  * @property {Node|string|number|{toElement: () => Node|null}|null} [content=null] Modal content.
@@ -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}
@@ -188,6 +277,12 @@ export class Modal extends Component {
188
277
  */
189
278
  open() {
190
279
  if (this.isOpen()) return this;
280
+ /*
281
+ * Captured before anything is presented, so it is the element the reader was actually on
282
+ * rather than whatever the overlay is about to focus. A subclass that traps focus before
283
+ * delegating here has already moved it and restores its own capture afterwards.
284
+ */
285
+ this.#opener = document.activeElement instanceof Element ? document.activeElement : null;
191
286
  const host = this.mountTarget();
192
287
  if (this.el.parentElement !== host) host.append(this.el);
193
288
  this.el.dataset.zxOverlayOrder = String(nextOverlayOrder());
@@ -196,10 +291,15 @@ export class Modal extends Component {
196
291
  this._present();
197
292
  } catch (error) {
198
293
  delete this.el.dataset.zxOverlayOrder;
294
+ // Nothing was presented, so nothing will close; a capture left behind would outlive its open.
295
+ this.#opener = null;
199
296
  throw error;
200
297
  }
201
298
  this.el.dataset.state = 'open';
202
299
  this.emit('open');
300
+ // Deferred by one microtask: the platform's own focusing step runs as part of presenting, and
301
+ // this has to be the one that settles, not the one it overwrites.
302
+ queueMicrotask(() => this._focusInitial());
203
303
  return this;
204
304
  }
205
305
 
@@ -278,6 +378,47 @@ export class Modal extends Component {
278
378
  * @type {CustomEvent<Record<string, never>>}
279
379
  */
280
380
 
381
+ /**
382
+ * Picks the control initial focus belongs on inside an overlay panel: an `autofocus` control when
383
+ * the content named one, the first focusable descendant otherwise, and null when the panel holds
384
+ * neither and must take focus itself.
385
+ *
386
+ * Candidates in a hidden, `aria-hidden` or `inert` subtree are skipped, because `focus()` on one
387
+ * is a silent no-op: a `Dialog` whose header close button is hidden by `closable: false`, or whose
388
+ * other registered views sit hidden in the same body, would otherwise "focus" nothing at all and
389
+ * leave the reader with no way into the overlay.
390
+ * @param {Element} root Overlay panel.
391
+ * @returns {HTMLElement|null}
392
+ */
393
+ export function initialFocusTarget(root) {
394
+ const declared = root.querySelector('[autofocus]:not([disabled])');
395
+ if (declared && isFocusable(declared, root)) return /** @type {HTMLElement} */ (declared);
396
+ for (const candidate of root.querySelectorAll(FOCUSABLE_SELECTOR)) {
397
+ if (isFocusable(candidate, root)) return /** @type {HTMLElement} */ (candidate);
398
+ }
399
+ return null;
400
+ }
401
+
402
+ /**
403
+ * Whether a candidate can take focus from where it sits inside the panel.
404
+ *
405
+ * Duck-typed on `focus` rather than tested against `HTMLElement`, so a focusable SVG or MathML
406
+ * element inside the panel is a candidate rather than a silently skipped one.
407
+ * @param {Element} element Candidate found by the focusable selector.
408
+ * @param {Element} root Panel the search is bounded by.
409
+ * @returns {boolean}
410
+ */
411
+ function isFocusable(element, root) {
412
+ if (typeof (/** @type {HTMLElement} */ (element).focus) !== 'function') return false;
413
+ for (let node = /** @type {Element|null} */ (element); node; node = node.parentElement) {
414
+ if (/** @type {HTMLElement} */ (node).hidden) return false;
415
+ if (node.getAttribute('aria-hidden') === 'true') return false;
416
+ if (node.hasAttribute('inert')) return false;
417
+ if (node === root) break;
418
+ }
419
+ return true;
420
+ }
421
+
281
422
  /**
282
423
  * @param {Element} target
283
424
  * @param {Node|string|number|{toElement: () => Node|null}|null|undefined} content
@@ -138,12 +138,87 @@
138
138
  box-shadow: inset var(--zx-focus-ring);
139
139
  }
140
140
 
141
+ /*
142
+ * Overflow.
143
+ *
144
+ * Three modes, written by the component onto the root as `data-overflow`:
145
+ *
146
+ * all — every item collapses (the default, `minVisible: 0`): the row is taken out and the
147
+ * More button replaces it, which is what a top app bar wants.
148
+ * partial — `minVisible` items stay in the row and the rest move into the menu. The row and
149
+ * the button are then on screen together, which is the one case the navigation has
150
+ * to lay them out side by side.
151
+ * off — `overflow: false` or `overflowBelow: false`: nothing ever collapses, and the
152
+ * More button never appears at any width.
153
+ *
154
+ * The threshold is the awkward part, because a container query cannot read a custom property. So
155
+ * the default threshold lives here, in the query below, and any other one is measured by the
156
+ * component, which writes `data-overflow-below` (stand down, this bar has its own threshold) and
157
+ * `data-narrow`. The two blocks carry the same three declarations for that reason — a change to
158
+ * one belongs in the other.
159
+ */
160
+ .zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__navigation {
161
+ display: flex;
162
+ align-items: center;
163
+ gap: var(--zx-space-1);
164
+ }
165
+
166
+ /*
167
+ * `partial` and `off` are the two modes that keep items in the row at a width the row was never
168
+ * going to fit: `minVisible` is a promise that those items stay, not that they fit, and
169
+ * `overflow: false` is the same promise for all of them. They give way by truncating their own
170
+ * labels rather than by spilling out of the bar. The badge and the More button keep their size —
171
+ * a count that says "4" and a button that says "More" have nothing to give.
172
+ *
173
+ * The default `all` mode is deliberately not in this list: it takes the whole row out instead, and
174
+ * it never has both on screen at once.
175
+ */
176
+ .zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__item,
177
+ .zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__label,
178
+ .zx-navigation-bar[data-overflow="off"] .zx-navigation-bar__item,
179
+ .zx-navigation-bar[data-overflow="off"] .zx-navigation-bar__label {
180
+ min-inline-size: 0;
181
+ }
182
+
183
+ .zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__label,
184
+ .zx-navigation-bar[data-overflow="off"] .zx-navigation-bar__label {
185
+ overflow: hidden;
186
+ text-overflow: ellipsis;
187
+ white-space: nowrap;
188
+ }
189
+
190
+ .zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__badge,
191
+ .zx-navigation-bar[data-overflow="partial"] .zx-navigation-bar__more,
192
+ .zx-navigation-bar[data-overflow="off"] .zx-navigation-bar__badge {
193
+ flex: none;
194
+ }
195
+
141
196
  @container (max-width: 44rem) {
142
- .zx-navigation-bar__items {
197
+ .zx-navigation-bar[data-overflow="all"]:not([data-overflow-below]) .zx-navigation-bar__items {
143
198
  display: none;
144
199
  }
145
200
 
146
- .zx-navigation-bar__more:not([data-empty="true"]) {
201
+ .zx-navigation-bar[data-overflow="partial"]:not([data-overflow-below])
202
+ .zx-navigation-bar__item[data-collapsed="true"] {
203
+ display: none;
204
+ }
205
+
206
+ .zx-navigation-bar:not([data-overflow="off"]):not([data-overflow-below])
207
+ .zx-navigation-bar__more:not([data-empty="true"]) {
147
208
  display: inline-flex;
148
209
  }
149
210
  }
211
+
212
+ .zx-navigation-bar[data-overflow="all"][data-narrow="true"] .zx-navigation-bar__items {
213
+ display: none;
214
+ }
215
+
216
+ .zx-navigation-bar[data-overflow="partial"][data-narrow="true"]
217
+ .zx-navigation-bar__item[data-collapsed="true"] {
218
+ display: none;
219
+ }
220
+
221
+ .zx-navigation-bar[data-narrow="true"]:not([data-overflow="off"])
222
+ .zx-navigation-bar__more:not([data-empty="true"]) {
223
+ display: inline-flex;
224
+ }