@zeyos/zx 5.0.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +2 -1
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +9 -1
  5. package/dist/types/components/app-icon/app-icon.d.ts +14 -2
  6. package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
  7. package/dist/types/components/calendar/calendar.d.ts +67 -5
  8. package/dist/types/components/card-view/card-view.d.ts +7 -4
  9. package/dist/types/components/card-view/record-card.d.ts +11 -0
  10. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  11. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  12. package/dist/types/components/dropdown/dropdown.d.ts +22 -7
  13. package/dist/types/components/field/field.d.ts +30 -0
  14. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  15. package/dist/types/components/fieldset/fieldset.d.ts +7 -0
  16. package/dist/types/components/file-list/file-list.d.ts +35 -3
  17. package/dist/types/components/filter/filter.d.ts +127 -3
  18. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  19. package/dist/types/components/launcher/launcher.d.ts +8 -0
  20. package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
  21. package/dist/types/components/menu-button/menu-button.d.ts +6 -0
  22. package/dist/types/components/message/message.d.ts +14 -1
  23. package/dist/types/components/modal/modal.d.ts +30 -0
  24. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  25. package/dist/types/components/pagination/pagination.d.ts +17 -3
  26. package/dist/types/components/select/select.d.ts +6 -0
  27. package/dist/types/components/sheet/sheet.d.ts +15 -0
  28. package/dist/types/components/table/table.d.ts +265 -17
  29. package/dist/types/components/view/record-view.d.ts +59 -2
  30. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  31. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  32. package/dist/types/core/component.d.ts +12 -2
  33. package/dist/types/core/i18n.d.ts +27 -3
  34. package/dist/types/core/keyboard.d.ts +98 -1
  35. package/dist/types/core/messages.d.ts +382 -0
  36. package/dist/types/core/overlay-host.d.ts +28 -1
  37. package/dist/types/core/overlay-stack.d.ts +260 -0
  38. package/dist/types/index.d.ts +5 -2
  39. package/dist/types/internal/app-rail.d.ts +38 -0
  40. package/dist/types/internal/menu-items.d.ts +71 -3
  41. package/dist/types/zeyos/index.d.ts +1 -0
  42. package/dist/types/zeyos/launcher.d.ts +9 -0
  43. package/dist/types/zeyos/module-bar.d.ts +176 -0
  44. package/dist/types/zeyos/query.d.ts +31 -5
  45. package/dist/types/zeyos/table.d.ts +245 -8
  46. package/dist/types/zeyos/view.d.ts +16 -2
  47. package/dist/zx-zeyos.esm.js +4422 -430
  48. package/dist/zx-zeyos.esm.min.js +5 -5
  49. package/dist/zx.css +1417 -134
  50. package/dist/zx.esm.js +6986 -830
  51. package/dist/zx.esm.min.js +8 -8
  52. package/dist/zx.global.js +6986 -830
  53. package/dist/zx.global.min.js +8 -8
  54. package/dist/zx.min.css +1 -1
  55. package/docs/api.json +794 -17
  56. package/docs/llms.md +503 -63
  57. package/docs/llms.txt +6 -2
  58. package/package.json +1 -1
  59. package/src/components/account-menu/account-menu.js +12 -0
  60. package/src/components/activity-list/activity-list.js +27 -1
  61. package/src/components/app-icon/app-icon.css +13 -2
  62. package/src/components/app-icon/app-icon.js +11 -1
  63. package/src/components/app-rail/app-rail.css +19 -0
  64. package/src/components/app-sidebar/app-sidebar.css +204 -3
  65. package/src/components/app-sidebar/app-sidebar.js +646 -40
  66. package/src/components/breadcrumb/breadcrumb.css +1 -1
  67. package/src/components/calendar/calendar.css +1 -1
  68. package/src/components/calendar/calendar.js +26 -52
  69. package/src/components/card/card.css +2 -2
  70. package/src/components/card-view/card-view.css +5 -5
  71. package/src/components/card-view/card-view.js +13 -9
  72. package/src/components/card-view/record-card.js +14 -3
  73. package/src/components/code-editor/code-editor.css +1 -1
  74. package/src/components/code-editor/field-adapter.js +9 -2
  75. package/src/components/context-menu/context-menu.js +8 -2
  76. package/src/components/copy/copy-input.css +1 -1
  77. package/src/components/data-filter/data-filter.js +2 -2
  78. package/src/components/date-picker/time-picker.css +2 -2
  79. package/src/components/dock/dock.css +5 -4
  80. package/src/components/dropdown/dropdown.js +157 -72
  81. package/src/components/field/field.js +33 -3
  82. package/src/components/fieldset/external-adapter.js +363 -0
  83. package/src/components/fieldset/fieldset.js +10 -59
  84. package/src/components/file-list/file-list.js +144 -21
  85. package/src/components/filter/filter.css +86 -2
  86. package/src/components/filter/filter.js +300 -8
  87. package/src/components/filter-panel/filter-panel.js +149 -32
  88. package/src/components/finder/finder.css +1 -1
  89. package/src/components/kanban-view/kanban-view.css +3 -3
  90. package/src/components/kanban-view/kanban-view.js +5 -2
  91. package/src/components/launcher/launcher.css +1 -1
  92. package/src/components/launcher/launcher.js +29 -3
  93. package/src/components/list-toolbar/list-toolbar.js +39 -13
  94. package/src/components/master-panel/master-panel.js +0 -1
  95. package/src/components/menu-button/menu-button.css +63 -0
  96. package/src/components/menu-button/menu-button.js +97 -11
  97. package/src/components/message/message.js +60 -20
  98. package/src/components/modal/modal.css +1 -1
  99. package/src/components/modal/modal.js +66 -13
  100. package/src/components/module-bar/module-bar.css +486 -0
  101. package/src/components/module-bar/module-bar.js +1074 -0
  102. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  103. package/src/components/number-field/field-adapter.js +2 -1
  104. package/src/components/pagination/pagination.css +5 -5
  105. package/src/components/pagination/pagination.js +20 -4
  106. package/src/components/panel/panel.css +3 -2
  107. package/src/components/questionnaire/questionnaire.css +4 -4
  108. package/src/components/rating/field-adapter.js +2 -1
  109. package/src/components/rating/rating.css +1 -1
  110. package/src/components/search/search.css +4 -1
  111. package/src/components/select/select.js +9 -0
  112. package/src/components/sheet/sheet.css +1 -1
  113. package/src/components/sheet/sheet.js +74 -32
  114. package/src/components/slider/field-adapter.js +6 -1
  115. package/src/components/slider/slider.css +2 -2
  116. package/src/components/split-view/split-view.css +1 -1
  117. package/src/components/stat-tile/stat-tile.css +1 -1
  118. package/src/components/stepper/stepper.css +1 -1
  119. package/src/components/tabbox/tabbox.css +9 -5
  120. package/src/components/table/table.css +86 -11
  121. package/src/components/table/table.js +628 -57
  122. package/src/components/tag-picker/field-adapter.js +2 -1
  123. package/src/components/tooltip/tooltip.js +35 -17
  124. package/src/components/tree/tree.css +1 -1
  125. package/src/components/view/record-view.css +1 -1
  126. package/src/components/view/record-view.js +178 -18
  127. package/src/components/view-settings/view-settings.css +285 -0
  128. package/src/components/view-settings/view-settings.js +1830 -0
  129. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  130. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  131. package/src/core/component.js +88 -17
  132. package/src/core/fontawesome.js +5 -1
  133. package/src/core/i18n.js +47 -4
  134. package/src/core/icons.js +12 -2
  135. package/src/core/keyboard.js +206 -11
  136. package/src/core/messages.js +427 -0
  137. package/src/core/overlay-host.js +64 -9
  138. package/src/core/overlay-stack.js +487 -0
  139. package/src/core/position.js +22 -3
  140. package/src/index.js +5 -2
  141. package/src/internal/app-rail.js +134 -53
  142. package/src/internal/menu-items.js +61 -3
  143. package/src/zeyos/form.js +5 -3
  144. package/src/zeyos/index.js +3 -0
  145. package/src/zeyos/launcher.js +8 -3
  146. package/src/zeyos/module-bar.js +181 -0
  147. package/src/zeyos/query.js +44 -13
  148. package/src/zeyos/table.js +220 -80
  149. package/src/zeyos/view.js +43 -93
  150. package/styles/base.css +16 -1
  151. package/styles/print.css +1 -1
  152. package/styles/tokens/dark.css +4 -0
  153. package/styles/tokens/semantic.css +56 -28
  154. package/styles/zx.css +3 -0
@@ -17,6 +17,9 @@ import { Tooltip } from '../components/tooltip/tooltip.js';
17
17
  * @property {() => void} [invoke] Application-owned non-link action.
18
18
  * @property {boolean} [disabled] Whether activation is unavailable.
19
19
  * @property {AppNavItem[]} [children] Child destinations/actions.
20
+ * @property {boolean} [section] Makes the item a section: a quiet heading over its `children`, which
21
+ * render at the same level. A section is never focused, selected, or collapsed, and needs no `id`.
22
+ * @property {Node|{toElement: () => Node|null}} [actions] A section's trailing controls, such as an add button.
20
23
  */
21
24
  /**
22
25
  * @typedef {Object} AppRailPresenterOptions
@@ -45,6 +48,14 @@ import { Tooltip } from '../components/tooltip/tooltip.js';
45
48
  export class AppRailPresenter extends Component {
46
49
  static cssName = 'app-rail';
47
50
 
51
+ /**
52
+ * BEM block of the rail's own parts (header, list, footer, row, item). A component built on the
53
+ * presenter names its parts after itself; flyout panels keep the `zx-app-rail__` vocabulary,
54
+ * because every rail portals the same panel.
55
+ * @type {string}
56
+ */
57
+ static partBlock = 'zx-app-rail';
58
+
48
59
  /** @type {Readonly<AppRailPresenterOptions>} */
49
60
  static defaults = {
50
61
  items: [],
@@ -93,13 +104,14 @@ export class AppRailPresenter extends Component {
93
104
  root.dataset.orientation = this.options.orientation === 'horizontal' ? 'horizontal' : 'vertical';
94
105
  const sides = root.dataset.orientation === 'horizontal' ? ['top', 'bottom'] : ['left', 'right'];
95
106
  root.dataset.side = sides.includes(this.options.side) ? this.options.side : sides[0];
96
- const header = h('div', { class: 'zx-app-rail__header' });
97
- const footer = h('div', { class: 'zx-app-rail__footer' });
107
+ const block = this._partBlock();
108
+ const header = h('div', { class: `${block}__header` });
109
+ const footer = h('div', { class: `${block}__footer` });
98
110
  appendSlot(header, this.options.header);
99
111
  appendSlot(footer, this.options.footer);
100
112
  header.hidden = !header.hasChildNodes();
101
113
  footer.hidden = !footer.hasChildNodes();
102
- root.replaceChildren(header, h('ul', { ref: 'list', class: 'zx-app-rail__list' }), footer);
114
+ root.replaceChildren(header, h('ul', { ref: 'list', class: `${block}__list` }), footer);
103
115
  this.listen(root, 'keydown', (event) => this._onRailKeydown(/** @type {KeyboardEvent} */ (event)));
104
116
  return root;
105
117
  }
@@ -209,31 +221,28 @@ export class AppRailPresenter extends Component {
209
221
  this._clearArtifacts();
210
222
  this.refs.list.replaceChildren();
211
223
  this._interaction = new RailListenerScope(/** @type {HTMLElement} */ (this.el));
212
- this._items.forEach((item) => {
213
- const hasChildren = Array.isArray(item.children) && item.children.length > 0;
214
- const control = navControl(item, {
215
- className: 'zx-app-rail__item',
216
- index: String(item.id),
217
- iconOnly: true,
218
- disclosure: hasChildren,
219
- disclosureIcon: railDisclosureIcon(this.el.dataset.side),
220
- visual: appNavIcon(item, this.options.renderIcon, {
221
- orientation: this.el.dataset.orientation,
222
- collapsed: true,
223
- side: this.el.dataset.side,
224
- level: 1,
225
- location: 'root'
226
- })
227
- });
228
- const row = h('li', { class: 'zx-app-rail__row' }, control);
224
+ let started = false;
225
+ for (const item of railEntries(this._items)) {
226
+ // A section has no rail control: the rail draws a rule between the groups instead.
227
+ if (item === SECTION_BREAK) {
228
+ if (started) this.refs.list.append(h('li', { class: `${this._partBlock()}__separator`, ariaHidden: 'true' }));
229
+ continue;
230
+ }
231
+ started = true;
232
+ const hasChildren = Array.isArray(item.children) && item.children.length > 0
233
+ && this._flyoutsFor(item);
234
+ const control = this._renderControl(item, hasChildren);
235
+ const row = h('li', { class: `${this._partBlock()}__row` }, control);
229
236
  this.refs.list.append(row);
230
- const tooltip = new Tooltip(control, {
231
- content: item.label,
232
- placement: tooltipPlacement(this.el.dataset.side),
233
- delay: 450
234
- });
235
- this._tooltips.push(tooltip);
236
- this._tooltipById.set(String(item.id), tooltip);
237
+ if (this._tooltipFor(item)) {
238
+ const tooltip = new Tooltip(control, {
239
+ content: item.label,
240
+ placement: tooltipPlacement(this.el.dataset.side),
241
+ delay: 450
242
+ });
243
+ this._tooltips.push(tooltip);
244
+ this._tooltipById.set(String(item.id), tooltip);
245
+ }
237
246
  if (hasChildren) this._buildFlyout(item, control, String(item.id), null);
238
247
  else this._interaction.listen(control, 'click', (event) => {
239
248
  const selected = this._select(item, event);
@@ -243,21 +252,66 @@ export class AppRailPresenter extends Component {
243
252
  }
244
253
  }
245
254
  });
246
- });
247
- this._interaction.listen(document, 'pointerdown', (event) => {
248
- const open = [...this._flyouts.values()].filter((flyout) => flyout.dropdown.isOpen());
249
- if (open.length === 0) return;
250
- const path = event.composedPath();
251
- if (open.some((flyout) => path.includes(flyout.panel) || path.includes(flyout.trigger))) return;
252
- for (const flyout of open) if (flyout.parentKey === null) this.closeFlyout(flyout.key);
253
- });
255
+ }
256
+ // Outside presses need no listener here: each open flyout is a Dropdown on the overlay stack,
257
+ // which closes it on a press outside it and every flyout opened from it, and a nested flyout,
258
+ // anchored inside its parent's panel, counts as part of that parent.
259
+ this._afterRebuild();
254
260
  this._syncActive();
255
261
  }
256
262
 
263
+ /** @returns {string} The BEM block of the rail's own parts. @protected */
264
+ _partBlock() {
265
+ return /** @type {typeof AppRailPresenter} */ (this.constructor).partBlock;
266
+ }
267
+
268
+ /**
269
+ * Builds one top-level rail control. The control must carry `data-app-nav-id` and
270
+ * `data-app-nav-value-id`, which keyboard handling, flyouts, and the active state read.
271
+ * @param {AppNavItem} item Top-level item.
272
+ * @param {boolean} hasFlyout Whether the control opens a flyout of the item's children.
273
+ * @returns {HTMLElement}
274
+ * @protected
275
+ */
276
+ _renderControl(item, hasFlyout) {
277
+ return navControl(item, {
278
+ className: 'zx-app-rail__item',
279
+ index: String(item.id),
280
+ iconOnly: true,
281
+ disclosure: hasFlyout,
282
+ disclosureIcon: railDisclosureIcon(this.el.dataset.side),
283
+ visual: appNavIcon(item, this.options.renderIcon, {
284
+ orientation: this.el.dataset.orientation,
285
+ collapsed: true,
286
+ side: this.el.dataset.side,
287
+ level: 1,
288
+ location: 'root'
289
+ })
290
+ });
291
+ }
292
+
293
+ /** Whether an item with children presents them in a flyout. @param {AppNavItem} item @returns {boolean} @protected */
294
+ _flyoutsFor(item) {
295
+ return true;
296
+ }
297
+
298
+ /** Whether a top-level control gets a hover/focus tooltip naming it. @param {AppNavItem} item @returns {boolean} @protected */
299
+ _tooltipFor(item) {
300
+ return true;
301
+ }
302
+
303
+ /** Runs after every rebuild, before the active state is applied. @returns {void} @protected */
304
+ _afterRebuild() {}
305
+
257
306
  /** @param {AppNavItem} item @param {HTMLElement} trigger @param {string} key @param {string|null} parentKey @returns {void} */
258
307
  _buildFlyout(item, trigger, key, parentKey) {
259
308
  const list = h('ul', { class: 'zx-app-rail__flyout-list' });
260
- for (const child of item.children ?? []) {
309
+ for (const child of railEntries(item.children ?? [], true)) {
310
+ if (child.section) {
311
+ // A caption, not a destination: keyboard movement skips it with every other non-action.
312
+ list.append(h('li', { class: 'zx-app-rail__flyout-section' }, child.label));
313
+ continue;
314
+ }
261
315
  const childKey = `${key}/${child.id}`;
262
316
  const hasChildren = Boolean(child.children?.length);
263
317
  const control = navControl(child, {
@@ -292,6 +346,17 @@ export class AppRailPresenter extends Component {
292
346
  offset: 6
293
347
  });
294
348
  const actualPanel = dropdown.getPanel();
349
+ // Escape arrives through the overlay stack, which offers it to the topmost flyout only. The
350
+ // rail returns focus itself, so the trigger's focus does not reopen what Escape just closed,
351
+ // and a flyout is reached with the arrow keys rather than Tab: every tile is its own Tab stop.
352
+ dropdown._configure({
353
+ tabReach: false,
354
+ onEscape: () => {
355
+ const hadFocus = actualPanel.contains(document.activeElement);
356
+ this.closeFlyout(key);
357
+ if (hadFocus) this._restoreFocus(trigger);
358
+ }
359
+ });
295
360
  actualPanel.classList.add('zx-app-rail__popover');
296
361
  // The panel is portaled out of the rail, so its stylesheet cannot ask which edge the rail
297
362
  // occupies. Presentation only: it tells the panel's styles which side it opened from.
@@ -300,10 +365,15 @@ export class AppRailPresenter extends Component {
300
365
  actualPanel.setAttribute('aria-label', this._message('appRail.subNavigation', '%1 sub-navigation', item.label));
301
366
  const flyout = { key, parentKey, item, trigger, panel: actualPanel, dropdown };
302
367
  this._flyouts.set(key, flyout);
368
+ // While a tile's own flyout is open its tooltip says nothing the flyout's title does not, and
369
+ // would take the first Escape meant for the flyout.
370
+ const tooltip = parentKey === null ? this._tooltipById.get(String(item.id)) : null;
303
371
  dropdown.on('open', () => {
372
+ tooltip?.disable();
304
373
  if (!this._silenceFlyoutEvents) this.emit('flyoutchange', { item, id: item.id, open: true });
305
374
  });
306
375
  dropdown.on('close', () => {
376
+ tooltip?.enable();
307
377
  if (!this._silenceFlyoutEvents) this.emit('flyoutchange', { item, id: item.id, open: false });
308
378
  });
309
379
 
@@ -393,20 +463,6 @@ export class AppRailPresenter extends Component {
393
463
  const controls = actions(flyout.panel);
394
464
  const current = /** @type {Element|null} */ (event.target)?.closest?.('a, button');
395
465
  const index = controls.indexOf(current);
396
- if (event.key === 'Escape') {
397
- event.preventDefault();
398
- event.stopPropagation();
399
- const nested = [...this._flyouts.values()].find((candidate) =>
400
- candidate.trigger === current && candidate.dropdown.isOpen());
401
- if (nested) {
402
- this.closeFlyout(nested.key);
403
- this._restoreFocus(nested.trigger);
404
- return;
405
- }
406
- this.closeFlyout(flyout.key);
407
- this._restoreFocus(flyout.trigger);
408
- return;
409
- }
410
466
  const nested = [...this._flyouts.values()].find((candidate) => candidate.trigger === current);
411
467
  const into = this.el.dataset.side === 'right' ? 'ArrowLeft' : 'ArrowRight';
412
468
  const out = into === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight';
@@ -416,7 +472,8 @@ export class AppRailPresenter extends Component {
416
472
  this.openFlyout(nested.key, { focus: true });
417
473
  return;
418
474
  }
419
- if (flyout.parentKey !== null && event.key === out) {
475
+ // Out of a nested flyout, or out of a top-level one beside a vertical rail, back to its trigger.
476
+ if ((flyout.parentKey !== null || this.el.dataset.orientation === 'vertical') && event.key === out) {
420
477
  event.preventDefault();
421
478
  event.stopPropagation();
422
479
  this.closeFlyout(flyout.key);
@@ -606,6 +663,22 @@ class RailListenerScope extends Component {
606
663
  }
607
664
  }
608
665
 
666
+ /** Marks the boundary of a section in a flattened rail. */
667
+ const SECTION_BREAK = Object.freeze({});
668
+
669
+ /**
670
+ * Flattens sections for a rail: a section's children take its place. With `labels`, each section
671
+ * leaves its own item ahead of its children, for a flyout to caption; otherwise a section break.
672
+ * @param {AppNavItem[]} items Normalized items.
673
+ * @param {boolean} [labels=false] Keep section items as captions.
674
+ * @returns {AppNavItem[]}
675
+ */
676
+ function railEntries(items, labels = false) {
677
+ return items.flatMap((item) => (item.section
678
+ ? [labels ? item : /** @type {AppNavItem} */ (SECTION_BREAK), ...railEntries(item.children ?? [], labels)]
679
+ : [item]));
680
+ }
681
+
609
682
  /**
610
683
  * Normalizes an application navigation tree without mutating caller data.
611
684
  * @param {unknown} items Candidate tree.
@@ -613,13 +686,21 @@ class RailListenerScope extends Component {
613
686
  */
614
687
  export function normalizeAppItems(items) {
615
688
  if (!Array.isArray(items)) return [];
616
- return items.filter((item) => item && typeof item === 'object' && item.id != null && item.label != null)
617
- .map((item) => {
689
+ return items.filter((item) => item && typeof item === 'object' && item.label != null
690
+ && (item.id != null || item.section === true))
691
+ .map((item, index) => {
618
692
  const normalized = {
619
693
  ...item,
694
+ // A section is addressed by nothing, so one written without an id gets a stable one.
695
+ id: item.id ?? `section:${index}:${item.label}`,
620
696
  label: String(item.label),
621
697
  children: Array.isArray(item.children) ? normalizeAppItems(item.children) : []
622
698
  };
699
+ if (item.section === true) {
700
+ normalized.section = true;
701
+ delete normalized.href;
702
+ return normalized;
703
+ }
623
704
  if (item.href == null) return normalized;
624
705
  const href = safeHref(item.href);
625
706
  if (href !== null) normalized.href = href;
@@ -1,5 +1,6 @@
1
1
  import { h, safeHref } from '../core/dom.js';
2
2
  import { icon } from '../core/icons.js';
3
+ import { uid } from '../core/util.js';
3
4
 
4
5
  /** @typedef {'menuitem'|'menuitemcheckbox'|'menuitemradio'} MenuItemRole */
5
6
  /**
@@ -31,7 +32,27 @@ import { icon } from '../core/icons.js';
31
32
  * @property {string} label Visible heading.
32
33
  */
33
34
  /** @typedef {{type: 'separator'}} MenuSeparator */
34
- /** @typedef {MenuItem|MenuHeading|MenuSeparator|'-'} MenuEntry */
35
+ /**
36
+ * One option of a menu choice.
37
+ * @typedef {Object} MenuChoiceOption
38
+ * @property {unknown} value Value the choice takes.
39
+ * @property {string} label Accessible name and tooltip; the option shows its glyph or icon.
40
+ * @property {string} [icon] Kernel icon name.
41
+ * @property {Node|(() => Node)} [glyph] A pictogram drawn instead of an icon.
42
+ * @property {boolean} [disabled=false] Whether the option is unavailable.
43
+ */
44
+ /**
45
+ * A setting chosen in place: a labelled row of two to a few options, one checked — a theme, a
46
+ * layout. Choosing an option keeps the menu open, so its effect shows.
47
+ * @typedef {Object} MenuChoice
48
+ * @property {'choice'} type Entry kind.
49
+ * @property {string} label Visible row label.
50
+ * @property {unknown} [value] Value of the checked option.
51
+ * @property {MenuChoiceOption[]} options The options, in order.
52
+ * @property {(value: unknown, entry: MenuChoice, controller: MenuController) => void} [onselect]
53
+ * Called with the chosen option's value.
54
+ */
55
+ /** @typedef {MenuItem|MenuHeading|MenuSeparator|MenuChoice|'-'} MenuEntry */
35
56
 
36
57
  export const MENU_ITEM_SELECTOR = [
37
58
  '[role="menuitem"]',
@@ -46,7 +67,7 @@ export const MENU_ITEM_SELECTOR = [
46
67
  */
47
68
  export function isMenuItem(entry) {
48
69
  return Boolean(entry && entry !== '-' && typeof entry === 'object'
49
- && !['heading', 'separator'].includes(String(/** @type {{type?: unknown}} */ (entry).type ?? '')));
70
+ && !['heading', 'separator', 'choice'].includes(String(/** @type {{type?: unknown}} */ (entry).type ?? '')));
50
71
  }
51
72
 
52
73
  /**
@@ -64,9 +85,11 @@ export function menuItemRole(item) {
64
85
  * Builds one menu's entries with shared structure and link safety.
65
86
  * @param {MenuEntry[]} entries Entry definitions.
66
87
  * @param {string} block CSS block name, without the `zx-` prefix.
88
+ * @param {{choices?: boolean}} [options={}] Whether the menu handles `choice` entries; a menu that
89
+ * does not leaves them out rather than drawing a row that does nothing.
67
90
  * @returns {HTMLElement[]}
68
91
  */
69
- export function renderMenuEntries(entries, block) {
92
+ export function renderMenuEntries(entries, block, { choices = false } = {}) {
70
93
  const nodes = [];
71
94
  entries.forEach((entry, index) => {
72
95
  if (entry === '-' || (entry && typeof entry === 'object' && entry.type === 'separator')) {
@@ -80,6 +103,10 @@ export function renderMenuEntries(entries, block) {
80
103
  }, String(entry.label ?? '')));
81
104
  return;
82
105
  }
106
+ if (entry && typeof entry === 'object' && entry.type === 'choice') {
107
+ if (choices) nodes.push(renderChoice(/** @type {MenuChoice} */ (entry), index, block));
108
+ return;
109
+ }
83
110
  if (!isMenuItem(entry)) return;
84
111
 
85
112
  const role = menuItemRole(entry);
@@ -140,3 +167,34 @@ export function renderMenuEntries(entries, block) {
140
167
  });
141
168
  return nodes;
142
169
  }
170
+
171
+ /**
172
+ * A choice row: its label, then a segmented group of `menuitemradio` options. The group is named
173
+ * by the label, so a screen reader hears "Theme, Dark, radio, checked, 2 of 3".
174
+ * @param {MenuChoice} entry Choice definition.
175
+ * @param {number} index Entry index.
176
+ * @param {string} block CSS block name, without the `zx-` prefix.
177
+ * @returns {HTMLElement}
178
+ */
179
+ function renderChoice(entry, index, block) {
180
+ const labelId = uid(`zx-${block}-choice`);
181
+ const options = Array.isArray(entry.options) ? entry.options : [];
182
+ return h('div', { class: `zx-${block}__choice`, role: 'group', ariaLabelledby: labelId },
183
+ h('span', { class: `zx-${block}__choice-label`, id: labelId }, String(entry.label ?? '')),
184
+ h('span', { class: `zx-${block}__choice-options` }, options.map((option, position) => {
185
+ const glyph = typeof option.glyph === 'function' ? option.glyph() : option.glyph;
186
+ return h('button', {
187
+ class: `zx-${block}__choice-option`,
188
+ type: 'button',
189
+ role: 'menuitemradio',
190
+ tabindex: '-1',
191
+ title: String(option.label ?? ''),
192
+ ariaLabel: String(option.label ?? ''),
193
+ ariaChecked: String(Object.is(option.value, entry.value)),
194
+ ariaDisabled: option.disabled ? 'true' : null,
195
+ 'data-menu-item': String(index),
196
+ 'data-menu-option': String(position)
197
+ }, glyph && typeof glyph === 'object' && 'nodeType' in glyph ? glyph
198
+ : option.icon ? icon(option.icon) : String(option.label ?? ''));
199
+ })));
200
+ }
package/src/zeyos/form.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Form, Message, Select } from '../index.js';
2
- import { overlayHost } from '../core/overlay-host.js';
2
+ import { overlayScope } from '../core/overlay-host.js';
3
3
  import { connect, errorMessage } from './connect.js';
4
4
  import {
5
5
  calendarDayToUnixSeconds, dateToUnixSeconds, unixSecondsToCalendarDay, unixSecondsToDate
@@ -163,8 +163,10 @@ export function zeyosForm(client, resource, opts = {}) {
163
163
  if (destroyed) return Promise.reject(new Error('Cannot save a destroyed ZeyOS form'));
164
164
  if (savePending) return savePending;
165
165
  if (!form.submit()) return Promise.resolve(null);
166
- // Resolved now, while the form is in place: `onSaved` may close the dialog that holds it.
167
- const host = notify ? overlayHost(toastScope()) : null;
166
+ // Resolved now, while the form is in place, and to the theme scope rather than the open
167
+ // dialog the overlay host would be: `onSaved` may close and destroy that dialog, and a toast
168
+ // parked inside it would fall back to an unstyled <body>.
169
+ const host = notify ? overlayScope(toastScope()) : null;
168
170
 
169
171
  savePending = (async () => {
170
172
  let payload;
@@ -20,6 +20,9 @@ export {
20
20
  ZEYOS_LAUNCHER_APPLICATIONS, buildZeyosLauncherConfig, zeyosLauncher,
21
21
  normalizeZeyosLauncherApplications, normalizeZeyosLauncherRecords, zeyosActiveIdentifier
22
22
  } from './launcher.js';
23
+ export {
24
+ zeyosModuleItems, buildZeyosModuleBarConfig, buildZeyosAppSidebarConfig, zeyosModuleBar
25
+ } from './module-bar.js';
23
26
  export {
24
27
  zeyosSelect, buildZeyosSelectConfig, zeyosEntitySelect, buildZeyosEntitySelectConfig
25
28
  } from './select.js';
@@ -224,8 +224,13 @@ export function zeyosActiveIdentifier(state = {}) {
224
224
  return String(state.module ?? '');
225
225
  }
226
226
 
227
- /** @param {unknown} raw @param {string|null} fallbackId @param {unknown} fallbackLabel @returns {Record<string, any>|null} */
228
- function normalizeApplication(raw, fallbackId, fallbackLabel) {
227
+ /**
228
+ * Normalizes one application of the ZeyOS shell catalogue: a `[module, label]` pair, a
229
+ * `[ID, name, module, color]` fork, a weblet row, or an object. Shared with the module bar.
230
+ * @param {unknown} raw @param {string|null} fallbackId @param {unknown} fallbackLabel
231
+ * @returns {Record<string, any>|null}
232
+ */
233
+ export function normalizeApplication(raw, fallbackId, fallbackLabel) {
229
234
  if (Array.isArray(raw)) {
230
235
  if (raw.length < 2) return null;
231
236
  if (raw.length === 2) {
@@ -305,7 +310,7 @@ function normalizeRecord(raw, group, query) {
305
310
  }
306
311
 
307
312
  /** @param {unknown} raw @param {unknown} resolver @returns {Record<string, any>} */
308
- function mergeDestination(raw, resolver) {
313
+ export function mergeDestination(raw, resolver) {
309
314
  const direct = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
310
315
  const resolved = typeof resolver === 'function' ? resolver(raw) : null;
311
316
  const destination = resolved && typeof resolved === 'object' ? resolved : {};
@@ -0,0 +1,181 @@
1
+ import { ModuleBar } from '../components/module-bar/module-bar.js';
2
+ import { normalizeAppItems } from '../internal/app-rail.js';
3
+ import { moduleIcon } from './icons.js';
4
+ import { mergeDestination, normalizeApplication } from './launcher.js';
5
+ import { moduleInfo } from './modules.js';
6
+
7
+ /** @typedef {import('../components/module-bar/module-bar.js').ModuleItem} ModuleItem */
8
+ /** @typedef {import('../components/module-bar/module-bar.js').ModuleBarOptions} ModuleBarOptions */
9
+ /** @typedef {import('../components/app-sidebar/app-sidebar.js').AppSidebarOptions} AppSidebarOptions */
10
+ /**
11
+ * One entry of a module's menu, as the ZeyOS shell describes it: an object, or an
12
+ * `[id, label, href]` array.
13
+ * @typedef {Object} ZeyosMenuEntry
14
+ * @property {string|number} [id] Entry identifier; a section needs none.
15
+ * @property {string} label Entry label.
16
+ * @property {string} [href] Destination.
17
+ * @property {string} [target] Link browsing context.
18
+ * @property {() => void} [invoke] Application-owned action.
19
+ * @property {string|number} [badge] Count.
20
+ * @property {string|Node} [icon] Icon name or node.
21
+ * @property {boolean} [section] A heading over `children` rather than a destination.
22
+ * @property {Array<ZeyosMenuEntry|Array<any>>} [children] Nested entries.
23
+ */
24
+ /**
25
+ * The ZeyOS shell state a module bar is built from. The application fields are the launcher's
26
+ * (`buildZeyosLauncherConfig()`), so one state object can feed both.
27
+ * @typedef {Object} ZeyosModuleBarState
28
+ * @property {Record<string, string>|Array<string|Record<string, any>>} [modules={}] Permitted module catalogue.
29
+ * @property {Array<Array<any>|Record<string, any>>} [menuApps=[]] Forks, weblets, and authoritative menu entries.
30
+ * @property {string[]} [pinned] Application identifiers on the bar, in order; the rest are under "All modules".
31
+ * @property {string|null} [activeIdentifier=null] Current module, `fork#…`, or `weblet#…` identifier.
32
+ * @property {string|number|null} [active=null] Current menu entry, when it is more precise than the module.
33
+ * @property {(module: string) => boolean} [canUseModule] Permission predicate.
34
+ * @property {(app: unknown) => Record<string, any>|null} [resolveApplication] Maps an application to href/invoke/value metadata.
35
+ * @property {Record<string, Array<ZeyosMenuEntry|Array<any>>>} [menus={}] Each application's menu, keyed by identifier.
36
+ * @property {Record<string, string|number>} [badges={}] Badges keyed by application identifier or menu entry id.
37
+ * @property {Record<string, string>} [groups={}] "All modules" group per application identifier.
38
+ * @property {{forks?: string, weblets?: string}} [labels] Group labels for forks and weblets.
39
+ * @property {(app: unknown, normalized: Record<string, any>) => Node|null} [renderApplicationIcon] Host icon renderer.
40
+ */
41
+ /**
42
+ * @typedef {Object} ZeyosModuleBarOptions
43
+ * @property {boolean} [standardIcons=false] Use stock Font Awesome glyphs instead of the ZeyOS kit.
44
+ * @property {'solid'|'tint'|'plain'} [appearance='solid'] Tile treatment; ZeyOS draws its modules solid.
45
+ */
46
+
47
+ /**
48
+ * Maps ZeyOS shell state to module bar items: every permitted module, fork, and weblet with its
49
+ * kit icon, its identity colour (the payload's, else the registry's), its menu, and its badge.
50
+ * A module with a menu is a panel module; one without is a destination.
51
+ * @param {ZeyosModuleBarState} [state={}] Application-owned shell state.
52
+ * @param {ZeyosModuleBarOptions} [options={}] Mapping options.
53
+ * @returns {ModuleItem[]}
54
+ */
55
+ export function zeyosModuleItems(state = {}, options = {}) {
56
+ const pinned = Array.isArray(state.pinned) ? state.pinned.map(String) : null;
57
+ const menus = state.menus && typeof state.menus === 'object' ? state.menus : {};
58
+ const badges = state.badges && typeof state.badges === 'object' ? state.badges : {};
59
+ const groups = state.groups && typeof state.groups === 'object' ? state.groups : {};
60
+ const labels = { forks: 'Forks', weblets: 'Weblets', ...(state.labels ?? {}) };
61
+ const seen = new Set();
62
+ /** @type {Record<string, any>[]} */
63
+ const items = [];
64
+ const add = (raw, fallbackId = null, fallbackLabel = null) => {
65
+ const app = normalizeApplication(raw, fallbackId, fallbackLabel);
66
+ if (!app || seen.has(app.identifier)) return;
67
+ if (app.type === 'module' && typeof state.canUseModule === 'function' && !state.canUseModule(app.module)) return;
68
+ seen.add(app.identifier);
69
+ const children = menuEntries(menus[app.identifier], badges);
70
+ const destination = children.length ? {} : mergeDestination(app.raw, state.resolveApplication);
71
+ const pin = pinned ? pinned.indexOf(app.identifier) : -1;
72
+ const group = groups[app.identifier]
73
+ ?? (app.type === 'fork' ? labels.forks : app.type === 'weblet' ? labels.weblets : undefined);
74
+ items.push({
75
+ id: app.identifier,
76
+ label: app.label,
77
+ // A factory, so a host renderer or the kit glyph is built per tile rather than shared.
78
+ icon: () => (typeof state.renderApplicationIcon === 'function' ? state.renderApplicationIcon(app.raw, app) : null)
79
+ ?? moduleIcon(app.module, { size: '1em', standard: Boolean(options.standardIcons) }),
80
+ color: app.color ?? moduleInfo(app.module).color,
81
+ module: app.module,
82
+ ...(group ? { group } : {}),
83
+ ...(badges[app.identifier] != null ? { badge: badges[app.identifier] } : {}),
84
+ ...(pinned ? { pinned: pin >= 0 ? pin : false } : {}),
85
+ ...(children.length ? { children } : destination)
86
+ });
87
+ };
88
+ for (const raw of Array.isArray(state.menuApps) ? state.menuApps : []) add(raw);
89
+ if (Array.isArray(state.modules)) {
90
+ for (const module of state.modules) {
91
+ if (typeof module === 'string') add([module, moduleInfo(module).label]);
92
+ else add(module, module?.id ?? module?.module, module?.label);
93
+ }
94
+ } else {
95
+ for (const [module, label] of Object.entries(state.modules ?? {})) add([module, label]);
96
+ }
97
+ return normalizeAppItems(items);
98
+ }
99
+
100
+ /**
101
+ * Builds plain ModuleBar options from ZeyOS shell state.
102
+ * @param {ZeyosModuleBarState} [state={}] Application-owned shell state.
103
+ * @param {ZeyosModuleBarOptions & ModuleBarOptions} [options={}] Mapping and ModuleBar options.
104
+ * @returns {ModuleBarOptions}
105
+ */
106
+ export function buildZeyosModuleBarConfig(state = {}, options = {}) {
107
+ const { standardIcons = false, appearance = 'solid', ...barOptions } = options;
108
+ return {
109
+ ...barOptions,
110
+ items: zeyosModuleItems(state, { standardIcons }),
111
+ active: state.active ?? state.activeIdentifier ?? null,
112
+ appearance
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Builds AppSidebar options for the modules layout from ZeyOS shell state: the module strip beside
118
+ * a panel with the current module's menu.
119
+ * @param {ZeyosModuleBarState} [state={}] Application-owned shell state.
120
+ * @param {ZeyosModuleBarOptions & AppSidebarOptions} [options={}] Mapping and AppSidebar options.
121
+ * @returns {AppSidebarOptions}
122
+ */
123
+ export function buildZeyosAppSidebarConfig(state = {}, options = {}) {
124
+ const { standardIcons = false, appearance = 'solid', moduleBar = {}, ...sidebarOptions } = options;
125
+ return {
126
+ label: 'ZeyOS',
127
+ ...sidebarOptions,
128
+ layout: 'modules',
129
+ items: zeyosModuleItems(state, { standardIcons }),
130
+ active: state.active ?? state.activeIdentifier ?? null,
131
+ moduleBar: { appearance, ...moduleBar }
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Creates a ModuleBar from ZeyOS shell state.
137
+ * @param {Element|string|null} target Bar target, or null to create one.
138
+ * @param {ZeyosModuleBarState} [state={}] Application-owned shell state.
139
+ * @param {ZeyosModuleBarOptions & ModuleBarOptions} [options={}] Mapping and ModuleBar options.
140
+ * @returns {ModuleBar}
141
+ */
142
+ export function zeyosModuleBar(target, state = {}, options = {}) {
143
+ return new ModuleBar(target, buildZeyosModuleBarConfig(state, options));
144
+ }
145
+
146
+ /**
147
+ * Normalizes a ZeyOS menu: objects and `[id, label, href]` arrays, sections, nested entries, and
148
+ * badges looked up by entry id. Entries without a label, and leaves without an id, are dropped.
149
+ * @param {unknown} entries Menu entries.
150
+ * @param {Record<string, string|number>} badges Badges by id.
151
+ * @returns {Record<string, any>[]}
152
+ */
153
+ function menuEntries(entries, badges) {
154
+ if (!Array.isArray(entries)) return [];
155
+ return entries.flatMap((entry) => {
156
+ if (Array.isArray(entry)) {
157
+ const [id, label, href] = entry;
158
+ if (id == null || label == null) return [];
159
+ return [{
160
+ id, label: String(label),
161
+ ...(href != null ? { href: String(href) } : {}),
162
+ ...(badges[String(id)] != null ? { badge: badges[String(id)] } : {})
163
+ }];
164
+ }
165
+ if (!entry || typeof entry !== 'object' || entry.label == null) return [];
166
+ const children = menuEntries(entry.children, badges);
167
+ if (entry.section === true) {
168
+ return children.length ? [{ ...(entry.id != null ? { id: entry.id } : {}), section: true, label: String(entry.label), children }] : [];
169
+ }
170
+ if (entry.id == null) return [];
171
+ const picked = Object.fromEntries(['id', 'href', 'target', 'invoke', 'icon', 'value', 'disabled']
172
+ .filter((key) => entry[key] !== undefined).map((key) => [key, entry[key]]));
173
+ const badge = badges[String(entry.id)] ?? entry.badge;
174
+ return [{
175
+ ...picked,
176
+ label: String(entry.label),
177
+ ...(badge != null ? { badge } : {}),
178
+ ...(children.length ? { children } : {})
179
+ }];
180
+ });
181
+ }