@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
@@ -6,23 +6,61 @@ import { uid } from '../../core/util.js';
6
6
  import {
7
7
  AppRailPresenter as MinimizedPresenter, appNavIcon, normalizeAppItems
8
8
  } from '../../internal/app-rail.js';
9
+ import { ModuleBar, liftSections, moduleOf } from '../module-bar/module-bar.js';
10
+ import { Tooltip } from '../tooltip/tooltip.js';
11
+
12
+ /** The modules layout's presentations, widest first. */
13
+ const PRESENTATIONS = Object.freeze(['expanded', 'compact', 'collapsed']);
9
14
 
10
15
  /** @typedef {import('../../internal/app-rail.js').AppNavItem} AppNavItem */
11
16
  /** @typedef {import('../../internal/app-rail.js').AppNavSelectDetail} AppNavSelectDetail */
17
+ /** @typedef {import('../module-bar/module-bar.js').ModuleItem} ModuleItem */
18
+ /**
19
+ * A module in the modules layout: a `ModuleItem` whose panel may carry content of its own.
20
+ * @typedef {ModuleItem & {header?: Node|{toElement: () => Node|null}, content?: Node|{toElement: () => Node|null}}} AppSidebarModule
21
+ */
22
+ /**
23
+ * Presentation of the module strip in the modules layout.
24
+ * @typedef {Object} AppSidebarModuleBarOptions
25
+ * @property {'tint'|'solid'|'plain'} [appearance='tint'] Tile treatment.
26
+ * @property {'auto'|'beside'|'below'|'hidden'} [labels='auto'] Module names on the strip: `'auto'`
27
+ * prints them beside the tiles while expanded and leaves the icons alone while collapsed;
28
+ * `'hidden'` keeps the icon strip beside the panel; `'below'` prints them under the tiles.
29
+ * @property {'auto'|'always'|'never'} [allModules='auto'] The "All modules" button.
30
+ * @property {string|null} [label=null] Strip accessible label; omitted resolves `moduleBar.label`.
31
+ */
12
32
  /**
13
33
  * @typedef {Object} AppSidebarOptions
14
- * @property {AppNavItem[]} [items=[]] Application navigation tree.
34
+ * @property {AppNavItem[]|AppSidebarModule[]} [items=[]] Application navigation tree; in the
35
+ * modules layout its top level is the modules.
15
36
  * @property {string|number|null} [active=null] Active item id.
16
37
  * @property {Array<string|number>} [expanded=[]] Initially expanded branch ids.
17
38
  * @property {boolean} [collapsed=false] Whether the minimized presentation is shown.
18
39
  * @property {boolean} [collapsible=true] Whether vertical navigation exposes a mode toggle.
19
40
  * @property {'vertical'|'horizontal'} [orientation='vertical'] Navigation flow; horizontal is always minimized.
20
41
  * @property {'left'|'right'|'top'|'bottom'} [side='left'] Workspace edge occupied by navigation.
42
+ * @property {'tree'|'modules'} [layout='tree'] `'modules'` draws the top level as a module strip
43
+ * beside a panel showing one module's menu; minimized, the strip remains and menus open as flyouts.
44
+ * @property {string|number|null} [module=null] Modules layout: the module the panel shows first.
45
+ * Defaults to the module containing `active`, else the first module with a panel.
46
+ * @property {AppSidebarModuleBarOptions} [moduleBar={}] Modules layout: strip presentation.
47
+ * @property {'expanded'|'compact'|'collapsed'|null} [presentation=null] Modules layout: how much
48
+ * of the navigation shows — both menus expanded (module names beside the side menu), the module
49
+ * menu collapsed to icons beside the side menu, or both collapsed to the icon strip. Defaults
50
+ * to `collapsed` ? `'collapsed'` : `'expanded'`.
51
+ * @property {Array<'expanded'|'compact'|'collapsed'>} [presentations=['expanded','compact','collapsed']]
52
+ * Modules layout: the presentations offered, widest first.
53
+ * @property {boolean} [presentationSwitch=true] Modules layout: whether the strip's foot carries the
54
+ * switch between presentations. Turn it off when the choice lives elsewhere — an account menu,
55
+ * through `presentationChoice()`.
21
56
  * @property {string} [label='Applications'] Navigation accessible label.
22
- * @property {Node|string|number|{toElement: () => Node|null}|null} [header=null] Expanded header content.
57
+ * @property {Node|string|number|{toElement: () => Node|null}|null} [header=null] Expanded header content;
58
+ * in the modules layout, content above every module's menu.
23
59
  * @property {Node|string|number|{toElement: () => Node|null}|null} [footer=null] Expanded sticky footer content.
24
- * @property {Node|string|number|{toElement: () => Node|null}|null} [railHeader=null] Minimized leading content.
25
- * @property {Node|string|number|{toElement: () => Node|null}|null} [railFooter=null] Minimized trailing content.
60
+ * @property {Node|string|number|{toElement: () => Node|null}|null} [railHeader=null] Minimized leading content;
61
+ * in the modules layout, the strip's leading content in both states.
62
+ * @property {Node|string|number|{toElement: () => Node|null}|null} [railFooter=null] Minimized trailing content;
63
+ * in the modules layout, the strip's trailing content in both states.
26
64
  * @property {number} [openDelay=80] Minimized flyout hover-open delay.
27
65
  * @property {number} [closeDelay=160] Minimized flyout crossing grace.
28
66
  * @property {(item: AppNavItem, context: Record<string, any>) => Node|null} [renderIcon] Optional host icon renderer.
@@ -30,15 +68,20 @@ import {
30
68
  * @property {(event: CustomEvent<{item: AppNavItem|null, id: string|number|null, expanded: boolean|null, ids: Array<string|number>}>) => void} [onbranchchange] Branch-state listener.
31
69
  * @property {(event: CustomEvent<{collapsed: boolean}>) => void} [oncollapsechange] Presentation-state listener.
32
70
  * @property {(event: CustomEvent<{item: AppNavItem, id: string|number, open: boolean}>) => void} [onflyoutchange] Minimized flyout-state listener.
71
+ * @property {(event: CustomEvent<{item: AppNavItem, id: string|number, previous: string|number|null}>) => void} [onmodulechange] Preventable module-switch listener.
72
+ * @property {(event: CustomEvent<{presentation: 'expanded'|'compact'|'collapsed', previous: 'expanded'|'compact'|'collapsed'}>) => void} [onpresentationchange] Presentation listener.
33
73
  */
34
74
 
35
75
  /**
36
76
  * One application navigation component. Vertical expanded descendants render inline; minimized
37
- * vertical and horizontal descendants use pointer-, focus-, and keyboard-accessible flyouts.
77
+ * vertical and horizontal descendants use pointer-, focus-, and keyboard-accessible flyouts. The
78
+ * modules layout puts a `ModuleBar` of the top level beside a panel with the current module's menu.
38
79
  * @fires AppSidebar#select
39
80
  * @fires AppSidebar#branchchange
40
81
  * @fires AppSidebar#collapsechange
41
82
  * @fires AppSidebar#flyoutchange
83
+ * @fires AppSidebar#modulechange
84
+ * @fires AppSidebar#presentationchange
42
85
  * @extends {Component<AppSidebarOptions>}
43
86
  */
44
87
  export class AppSidebar extends Component {
@@ -53,6 +96,12 @@ export class AppSidebar extends Component {
53
96
  collapsible: true,
54
97
  orientation: 'vertical',
55
98
  side: 'left',
99
+ layout: 'tree',
100
+ module: null,
101
+ moduleBar: {},
102
+ presentation: null,
103
+ presentations: ['expanded', 'compact', 'collapsed'],
104
+ presentationSwitch: true,
56
105
  label: 'Applications',
57
106
  header: null,
58
107
  footer: null,
@@ -93,6 +142,16 @@ export class AppSidebar extends Component {
93
142
  this._collapsed = Boolean(this.options.collapsed);
94
143
  this._orientation = this.options.orientation === 'horizontal' ? 'horizontal' : 'vertical';
95
144
  this._side = normalizeSide(this._orientation, this.options.side);
145
+ this._modules = this.options.layout === 'modules';
146
+ this._moduleLabels = this.options.moduleBar?.labels ?? 'auto';
147
+ this._compact = false;
148
+ this._presentationGroup = null;
149
+ this._stripTooltips = [];
150
+ if (this._modules && PRESENTATIONS.includes(this.options.presentation)) {
151
+ this._collapsed = this.options.presentation === 'collapsed';
152
+ this._compact = this.options.presentation === 'compact';
153
+ }
154
+ this._shownId = this._modules ? this._resolveShown(this.options.module) : null;
96
155
  this._modeAbort = null;
97
156
  root.replaceChildren();
98
157
  return root;
@@ -102,7 +161,12 @@ export class AppSidebar extends Component {
102
161
  setItems(items) {
103
162
  this._items = normalizeAppItems(items);
104
163
  this._reconcileExpanded();
105
- if (this._rail) {
164
+ if (this._modules) {
165
+ this._shownId = this._resolveShown(this._shownId);
166
+ this._rail?.setItems(this._items);
167
+ if (this._rail instanceof ModuleBar && !this.isCollapsed()) this._rail.setExpanded(this._shownId);
168
+ if (this.refs.list) this._renderPanelContent();
169
+ } else if (this._rail) {
106
170
  this._rail.setItems(this._items);
107
171
  this._decorateRail();
108
172
  } else this._renderTree();
@@ -118,19 +182,110 @@ export class AppSidebar extends Component {
118
182
  return copyItems(this._items);
119
183
  }
120
184
 
121
- /** Sets the active id without routing. @param {string|number|null} id @returns {this} */
185
+ /**
186
+ * Sets the active id without routing. In the modules layout the panel follows the active route
187
+ * into its module.
188
+ * @param {string|number|null} id
189
+ * @returns {this}
190
+ */
122
191
  setActive(id) {
123
192
  this._active = id;
124
193
  if (this._rail) this._rail.setActive(id);
125
- else this._syncActive();
194
+ if (this._modules) {
195
+ const owner = moduleOf(this._moduleList(), id);
196
+ if (owner && isPanelModule(owner) && String(owner.id) !== String(this._shownId)) {
197
+ this._shownId = owner.id;
198
+ if (this._rail instanceof ModuleBar && !this.isCollapsed()) this._rail.setExpanded(owner.id);
199
+ if (this.refs.list) this._renderPanelContent();
200
+ return this;
201
+ }
202
+ }
203
+ if (this.refs.list) this._syncActive();
126
204
  return this;
127
205
  }
128
206
 
207
+ /**
208
+ * Updates one badge — a module's, or any destination's — without rebuilding the navigation.
209
+ * @param {string|number} id Item id.
210
+ * @param {string|number|null} badge New badge; null removes it.
211
+ * @returns {this}
212
+ */
213
+ setBadge(id, badge) {
214
+ const item = findItem(this._items, id);
215
+ if (!item) return this;
216
+ if (badge == null || badge === '') delete item.badge;
217
+ else item.badge = badge;
218
+ if (this._rail instanceof ModuleBar) this._rail.setBadge(id, badge);
219
+ else if (this._rail) {
220
+ this._rail.setItems(this._items);
221
+ this._decorateRail();
222
+ }
223
+ if (this.refs.list) this._renderTree();
224
+ return this;
225
+ }
226
+
227
+ /**
228
+ * Modules layout: shows a module's menu in the panel. Fires a preventable `modulechange` unless silent.
229
+ * @param {string|number} id Module id.
230
+ * @param {{silent?: boolean}} [options={}]
231
+ * @returns {this}
232
+ */
233
+ setModule(id, { silent = false } = {}) {
234
+ this._showModule(id, { silent });
235
+ return this;
236
+ }
237
+
238
+ /** Modules layout: the module whose menu the panel shows. @returns {string|number|null} */
239
+ getModule() {
240
+ return this._shownId;
241
+ }
242
+
243
+ /**
244
+ * Modules layout: whether the strip prints module names — `'auto'` (beside the tiles while
245
+ * expanded, none while collapsed), `'beside'`, `'below'`, or `'hidden'`. A narrow drawer that
246
+ * has no room for a labelled strip beside the panel sets `'hidden'`.
247
+ * @param {'auto'|'beside'|'below'|'hidden'} labels
248
+ * @returns {this}
249
+ */
250
+ setModuleLabels(labels) {
251
+ this._moduleLabels = labels;
252
+ if (this._rail instanceof ModuleBar) this._rail.setLabels(this._stripLabels());
253
+ this._syncPresentationSwitch();
254
+ return this;
255
+ }
256
+
257
+ /**
258
+ * How much of the navigation shows: `'expanded'` (in the modules layout, both menus),
259
+ * `'compact'` (the module menu collapsed to icons beside the side menu), or `'collapsed'`.
260
+ * @returns {'expanded'|'compact'|'collapsed'}
261
+ */
262
+ getPresentation() {
263
+ if (this.isCollapsed()) return 'collapsed';
264
+ return this._modules && this._stripLabels() === 'hidden' ? 'compact' : 'expanded';
265
+ }
266
+
267
+ /**
268
+ * Shows the navigation as `'expanded'`, `'compact'` (modules layout), or `'collapsed'`.
269
+ * @param {'expanded'|'compact'|'collapsed'} presentation
270
+ * @param {{silent?: boolean}} [options={}]
271
+ * @returns {this}
272
+ */
273
+ setPresentation(presentation, { silent = false } = {}) {
274
+ if (!PRESENTATIONS.includes(presentation)) return this;
275
+ return this._present(presentation === 'collapsed',
276
+ presentation === 'collapsed' ? this._compact : this._modules && presentation === 'compact', { silent });
277
+ }
278
+
279
+ /** Moves to the next presentation the strip's switch offers, wrapping (Mod+B in an application). @returns {this} */
280
+ togglePresentation() {
281
+ return this.setPresentation(this._nextPresentation());
282
+ }
283
+
129
284
  /** Replaces expanded branch ids. @param {Array<string|number>} ids @param {{silent?: boolean}} [options={}] @returns {this} */
130
285
  setExpanded(ids, { silent = false } = {}) {
131
286
  this._expanded = new Set(Array.isArray(ids) ? ids.map(String) : []);
132
287
  this._reconcileExpanded();
133
- if (!this._rail) this._renderTree();
288
+ if (this.refs.list) this._renderTree();
134
289
  if (!silent) this.emit('branchchange', {
135
290
  item: null,
136
291
  id: null,
@@ -152,12 +307,12 @@ export class AppSidebar extends Component {
152
307
  /** Toggles one inline branch. @param {string|number} id @param {{silent?: boolean}} [options={}] @returns {this} */
153
308
  toggleBranch(id, { silent = false } = {}) {
154
309
  const item = findItem(this._items, id);
155
- if (!item?.children?.length) return this;
310
+ if (!item?.children?.length || item.section) return this;
156
311
  const key = String(id);
157
312
  const expanded = !this._expanded.has(key);
158
313
  if (expanded) this._expanded.add(key);
159
314
  else this._expanded.delete(key);
160
- if (!this._rail) {
315
+ if (this.refs.list) {
161
316
  this._renderTree();
162
317
  if (expanded) this._markBranchMotion(key);
163
318
  }
@@ -182,19 +337,45 @@ export class AppSidebar extends Component {
182
337
 
183
338
  /** Sets the saved vertical expanded/minimized state. @param {boolean} collapsed @param {{silent?: boolean}} [options={}] @returns {this} */
184
339
  setCollapsed(collapsed, { silent = false } = {}) {
185
- const previous = this.isCollapsed();
340
+ return this._present(Boolean(collapsed), this._compact, { silent });
341
+ }
342
+
343
+ /**
344
+ * Applies a collapsed state and, in the modules layout, whether the module menu is collapsed
345
+ * beside an expanded side menu; reports what changed.
346
+ * @param {boolean} collapsed
347
+ * @param {boolean} compact
348
+ * @param {{silent?: boolean}} options
349
+ * @returns {this}
350
+ */
351
+ _present(collapsed, compact, { silent = false }) {
352
+ const previous = { collapsed: this.isCollapsed(), presentation: this.getPresentation() };
186
353
  const active = document.activeElement;
187
354
  const outgoingOwnedFocus = Boolean(active
188
355
  && (this.el.contains(active) || this._rail?._containsNode(active)));
189
- this._collapsed = Boolean(collapsed);
356
+ this._collapsed = collapsed;
357
+ this._compact = Boolean(compact);
190
358
  const next = this.isCollapsed();
191
- if (previous !== next) {
359
+ if (previous.collapsed !== next) {
192
360
  this._renderMode();
193
361
  if (outgoingOwnedFocus) {
194
- if (next) this._railExpandButton?.focus();
195
- else this.refs.collapse?.focus();
362
+ // The switch's choice when there is a switch; otherwise the strip's own Tab stop, so focus
363
+ // never falls to the page when the choice was made from a menu or a shortcut.
364
+ const control = next ? this._railExpandButton : this.refs.collapse;
365
+ if (control?.isConnected) control.focus();
366
+ else this.focus();
367
+ }
368
+ } else if (this._rail instanceof ModuleBar) {
369
+ // Only the strip's names change; the side menu and focus inside it stay as they are.
370
+ this._rail.setLabels(this._stripLabels());
371
+ this._syncPresentationSwitch();
372
+ }
373
+ const presentation = this.getPresentation();
374
+ if (!silent) {
375
+ if (previous.collapsed !== next) this.emit('collapsechange', { collapsed: next });
376
+ if (previous.presentation !== presentation) {
377
+ this.emit('presentationchange', { presentation, previous: previous.presentation });
196
378
  }
197
- if (!silent) this.emit('collapsechange', { collapsed: next });
198
379
  }
199
380
  return this;
200
381
  }
@@ -262,7 +443,7 @@ export class AppSidebar extends Component {
262
443
  return this._rail?.isFlyoutOpen(id) ?? false;
263
444
  }
264
445
 
265
- /** Focuses the first navigation action. @returns {this} */
446
+ /** Focuses the first navigation action: the module strip's Tab stop in the modules layout. @returns {this} */
266
447
  focus() {
267
448
  if (this._rail) this._rail.focus();
268
449
  else visibleTreeControls(this.refs.list)[0]?.focus();
@@ -274,6 +455,7 @@ export class AppSidebar extends Component {
274
455
  const original = this._original;
275
456
  this._modeAbort?.abort();
276
457
  this._modeAbort = null;
458
+ for (const tooltip of this._stripTooltips.splice(0)) tooltip.destroy();
277
459
  this._rail?.destroy();
278
460
  this._rail = null;
279
461
  super.destroy();
@@ -288,15 +470,21 @@ export class AppSidebar extends Component {
288
470
  _renderMode() {
289
471
  this._modeAbort?.abort();
290
472
  this._modeAbort = new AbortController();
473
+ for (const tooltip of this._stripTooltips.splice(0)) tooltip.destroy();
474
+ this._presentationGroup = null;
291
475
  this._rail?.destroy();
292
476
  this._rail = null;
293
477
  this._railExpandButton = null;
294
- for (const key of ['expanded', 'list', 'collapse', 'rail']) delete this.refs[key];
478
+ for (const key of ['expanded', 'list', 'collapse', 'rail', 'panelTitle', 'panelLead', 'panelContent', 'panelNav']) {
479
+ delete this.refs[key];
480
+ }
295
481
  this.el.replaceChildren();
296
482
  this.el.dataset.orientation = this._orientation;
297
483
  this.el.dataset.side = this._side;
298
484
  this.el.dataset.collapsed = String(this.isCollapsed());
299
- if (this.isCollapsed()) this._renderRail();
485
+ this.el.dataset.layout = this._modules ? 'modules' : 'tree';
486
+ if (this._modules) this._renderModules();
487
+ else if (this.isCollapsed()) this._renderRail();
300
488
  else this._renderExpanded();
301
489
  }
302
490
 
@@ -304,12 +492,7 @@ export class AppSidebar extends Component {
304
492
  _renderExpanded() {
305
493
  const headerContent = h('div', { class: 'zx-app-sidebar__header-content' });
306
494
  appendSlot(headerContent, this.options.header);
307
- const collapseButton = h('button', {
308
- class: 'zx-app-sidebar__collapse zx-icon-btn',
309
- type: 'button',
310
- ariaLabel: this._message('appSidebar.minimize', 'Minimize application sidebar')
311
- }, icon(this._side === 'right' ? 'chevron-right' : 'chevron-left'));
312
- collapseButton.hidden = !this.options.collapsible;
495
+ const collapseButton = this._collapseButton();
313
496
  const footer = h('footer', { class: 'zx-app-sidebar__footer' });
314
497
  appendSlot(footer, this.options.footer);
315
498
  footer.hidden = !footer.hasChildNodes();
@@ -322,10 +505,38 @@ export class AppSidebar extends Component {
322
505
  this.refs.list = list;
323
506
  this.refs.collapse = collapseButton;
324
507
  this.el.append(expanded);
325
- this._listenMode(collapseButton, 'click', () => this.collapse());
508
+ this._listenTree(list);
509
+ this._renderTree();
510
+ }
511
+
512
+ /** @returns {HTMLButtonElement} The minimize control of an expanded presentation. */
513
+ _collapseButton() {
514
+ const button = /** @type {HTMLButtonElement} */ (h('button', {
515
+ class: 'zx-app-sidebar__collapse zx-icon-btn',
516
+ type: 'button',
517
+ ariaLabel: this._message('appSidebar.minimize', 'Minimize application sidebar')
518
+ }, icon(this._side === 'right' ? 'chevron-right' : 'chevron-left')));
519
+ button.hidden = !this.options.collapsible;
520
+ this._listenMode(button, 'click', () => this.collapse());
521
+ return button;
522
+ }
523
+
524
+ /** @returns {HTMLButtonElement} The expand control at the head of a minimized vertical rail. */
525
+ _expandButton() {
526
+ const button = /** @type {HTMLButtonElement} */ (h('button', {
527
+ class: 'zx-app-sidebar__rail-toggle zx-icon-btn',
528
+ type: 'button',
529
+ ariaLabel: this._message('appSidebar.expand', 'Expand application sidebar')
530
+ }, icon(this._side === 'right' ? 'chevron-left' : 'chevron-right')));
531
+ this._railExpandButton = button;
532
+ this._listenMode(button, 'click', () => this.expand());
533
+ return button;
534
+ }
535
+
536
+ /** @param {HTMLElement} list @returns {void} */
537
+ _listenTree(list) {
326
538
  this._listenMode(list, 'click', (event) => this._onTreeClick(event));
327
539
  this._listenMode(list, 'keydown', (event) => this._onTreeKeydown(/** @type {KeyboardEvent} */ (event)));
328
- this._renderTree();
329
540
  }
330
541
 
331
542
  /** @returns {void} */
@@ -335,16 +546,7 @@ export class AppSidebar extends Component {
335
546
  this.refs.rail = railHost;
336
547
  const showToggle = this._orientation === 'vertical' && Boolean(this.options.collapsible);
337
548
  const railHeader = h('div', { class: 'zx-app-sidebar__rail-header' });
338
- if (showToggle) {
339
- const expandButton = h('button', {
340
- class: 'zx-app-sidebar__rail-toggle zx-icon-btn',
341
- type: 'button',
342
- ariaLabel: this._message('appSidebar.expand', 'Expand application sidebar')
343
- }, icon(this._side === 'right' ? 'chevron-left' : 'chevron-right'));
344
- railHeader.append(expandButton);
345
- this._railExpandButton = expandButton;
346
- this._listenMode(expandButton, 'click', () => this.expand());
347
- }
549
+ if (showToggle) railHeader.append(this._expandButton());
348
550
  appendSlot(railHeader, this.options.railHeader);
349
551
  this._rail = new MinimizedPresenter(railHost, {
350
552
  items: this._items,
@@ -368,6 +570,324 @@ export class AppSidebar extends Component {
368
570
  this._decorateRail();
369
571
  }
370
572
 
573
+ /**
574
+ * The modules layout. Expanded, the strip leaves module menus to the panel beside it; minimized
575
+ * or horizontal, the strip is the whole navigation and menus open as flyouts.
576
+ * @returns {void}
577
+ */
578
+ _renderModules() {
579
+ const expanded = !this.isCollapsed();
580
+ const stripHeader = h('div', { class: 'zx-app-sidebar__rail-header' });
581
+ appendSlot(stripHeader, this.options.railHeader);
582
+ // One control for both levels, at the foot of the strip above whatever the strip ends with:
583
+ // expanded, the module names and the panel; collapsed, the icons alone.
584
+ const stripFoot = h('div', { class: 'zx-app-sidebar__strip-foot' });
585
+ if (this._orientation === 'vertical' && this.options.collapsible && this.options.presentationSwitch !== false) {
586
+ stripFoot.append(this._presentationSwitch(expanded));
587
+ }
588
+ appendSlot(stripFoot, this.options.railFooter);
589
+ const host = h('nav', { class: 'zx-app-sidebar__strip' });
590
+ const panelId = expanded ? uid('zx-app-sidebar-panel') : null;
591
+ const container = expanded ? h('div', { class: 'zx-app-sidebar__modules' }, host) : host;
592
+ this.el.append(container);
593
+ this.refs.rail = host;
594
+ const presentation = this.options.moduleBar && typeof this.options.moduleBar === 'object'
595
+ ? this.options.moduleBar : {};
596
+ /** @type {Record<string, any>} */
597
+ const barOptions = {
598
+ items: this._items,
599
+ active: this._active,
600
+ orientation: this._orientation,
601
+ side: this._side,
602
+ menus: expanded ? 'none' : 'flyout',
603
+ expanded: expanded ? this._shownId : null,
604
+ controls: panelId,
605
+ header: stripHeader.hasChildNodes() ? stripHeader : null,
606
+ footer: stripFoot.hasChildNodes() ? stripFoot : null,
607
+ openDelay: this.options.openDelay,
608
+ closeDelay: this.options.closeDelay,
609
+ renderIcon: this.options.renderIcon,
610
+ onselect: (event) => this._onModuleSelect(event, expanded),
611
+ onflyoutchange: (event) => this.emit('flyoutchange', event.detail)
612
+ };
613
+ for (const key of ['appearance', 'allModules', 'label']) {
614
+ if (presentation[key] != null) barOptions[key] = presentation[key];
615
+ }
616
+ barOptions.labels = this._stripLabels();
617
+ this._rail = new ModuleBar(host, barOptions);
618
+ for (const type of ['zx-select', 'zx-flyoutchange', 'zx-overflowchange', 'zx-allmodules']) {
619
+ this._listenMode(host, type, (event) => event.stopPropagation());
620
+ }
621
+ if (!expanded) return;
622
+ this._listenMode(host, 'keydown', (event) => this._onStripKeydown(/** @type {KeyboardEvent} */ (event)));
623
+ container.append(this._renderPanel(/** @type {string} */ (panelId)));
624
+ }
625
+
626
+ /**
627
+ * The modules layout's switch between its presentations: an APG radio group of one small
628
+ * pictogram per presentation — both menus, the side menu beside module icons, the icons alone.
629
+ * Its checked option is also the collapse control while expanded and the expand control while
630
+ * collapsed, so focus lands on the checked option of its successor across a re-render.
631
+ * @param {boolean} expanded Whether the side menu shows.
632
+ * @returns {HTMLElement}
633
+ */
634
+ _presentationSwitch(expanded) {
635
+ const labels = this._presentationLabels();
636
+ const group = h('div', {
637
+ class: 'zx-app-sidebar__presentation',
638
+ role: 'radiogroup',
639
+ ariaLabel: this._message('appSidebar.presentation', 'Navigation layout')
640
+ });
641
+ for (const name of this._presentationCycle()) {
642
+ const option = /** @type {HTMLButtonElement} */ (h('button', {
643
+ class: 'zx-app-sidebar__presentation-option',
644
+ type: 'button',
645
+ role: 'radio',
646
+ ariaLabel: labels[name],
647
+ dataset: { presentation: name }
648
+ }, presentationGlyph(name)));
649
+ group.append(option);
650
+ this._stripTooltips.push(new Tooltip(option, {
651
+ content: labels[name],
652
+ placement: this._side === 'right' ? 'left' : 'right',
653
+ delay: 450
654
+ }));
655
+ }
656
+ this._listenMode(group, 'click', (event) => {
657
+ const option = /** @type {Element} */ (event.target).closest?.('.zx-app-sidebar__presentation-option');
658
+ if (option instanceof HTMLElement) this.setPresentation(/** @type {any} */ (option.dataset.presentation));
659
+ });
660
+ this._listenMode(group, 'keydown', (event) => this._onPresentationKeydown(/** @type {KeyboardEvent} */ (event)));
661
+ this._presentationGroup = group;
662
+ this._syncPresentationSwitch();
663
+ const checked = /** @type {HTMLElement|null} */ (group.querySelector('[aria-checked="true"]'));
664
+ if (expanded) this.refs.collapse = checked ?? group;
665
+ else this._railExpandButton = checked ?? group;
666
+ return group;
667
+ }
668
+
669
+ /**
670
+ * The choice between presentations as a menu entry, for an account or settings menu that holds
671
+ * it instead of the strip: `account.setItems([sidebar.presentationChoice(), …])`. It reads the
672
+ * current presentation when called, so rebuild it after `presentationchange`.
673
+ * @param {{label?: string}} [options={}] Row label; omitted resolves `appSidebar.presentation`.
674
+ * @returns {import('../../internal/menu-items.js').MenuChoice}
675
+ */
676
+ presentationChoice({ label } = {}) {
677
+ const labels = this._presentationLabels();
678
+ return {
679
+ type: 'choice',
680
+ label: label ?? this._message('appSidebar.presentation', 'Navigation layout'),
681
+ value: this.getPresentation(),
682
+ options: this._presentationCycle().map((name) => ({
683
+ value: name, label: labels[name], glyph: () => presentationGlyph(name)
684
+ })),
685
+ onselect: (value) => this.setPresentation(/** @type {any} */ (value))
686
+ };
687
+ }
688
+
689
+ /** @returns {Record<'expanded'|'compact'|'collapsed', string>} */
690
+ _presentationLabels() {
691
+ return {
692
+ expanded: this._message('appSidebar.presentationExpanded', 'Both menus expanded'),
693
+ compact: this._message('appSidebar.presentationCompact', 'Module menu collapsed'),
694
+ collapsed: this._message('appSidebar.presentationCollapsed', 'Both menus collapsed')
695
+ };
696
+ }
697
+
698
+ /** Arrow keys move through the presentations and choose as they go, as in any radio group. @param {KeyboardEvent} event @returns {void} */
699
+ _onPresentationKeydown(event) {
700
+ const options = /** @type {HTMLElement[]} */ ([...(this._presentationGroup?.querySelectorAll('.zx-app-sidebar__presentation-option') ?? [])]);
701
+ const index = options.indexOf(/** @type {HTMLElement} */ (event.target));
702
+ if (index < 0 || event.altKey || event.ctrlKey || event.metaKey) return;
703
+ let next;
704
+ if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = options[(index + 1) % options.length];
705
+ else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = options[(index - 1 + options.length) % options.length];
706
+ else if (event.key === 'Home') next = options[0];
707
+ else if (event.key === 'End') next = options.at(-1);
708
+ else return;
709
+ event.preventDefault();
710
+ const name = next.dataset.presentation;
711
+ next.focus();
712
+ this.setPresentation(/** @type {any} */ (name));
713
+ // A change across collapsed re-renders the strip: focus goes to the new switch's choice.
714
+ const replacement = this._presentationGroup?.querySelector(`.zx-app-sidebar__presentation-option[data-presentation="${name}"]`);
715
+ if (replacement instanceof HTMLElement && replacement !== document.activeElement) replacement.focus();
716
+ }
717
+
718
+ /** Checks the current presentation's option and makes it the group's one Tab stop. @returns {void} */
719
+ _syncPresentationSwitch() {
720
+ const group = this._presentationGroup;
721
+ if (!group) return;
722
+ const current = this.getPresentation();
723
+ for (const option of /** @type {HTMLElement[]} */ ([...group.querySelectorAll('.zx-app-sidebar__presentation-option')])) {
724
+ const checked = option.dataset.presentation === current;
725
+ option.setAttribute('aria-checked', String(checked));
726
+ option.tabIndex = checked ? 0 : -1;
727
+ }
728
+ if (!group.querySelector('[aria-checked="true"]')) {
729
+ const first = /** @type {HTMLElement|null} */ (group.querySelector('.zx-app-sidebar__presentation-option'));
730
+ if (first) first.tabIndex = 0;
731
+ }
732
+ }
733
+
734
+ /** @returns {Array<'expanded'|'compact'|'collapsed'>} The presentations the switch offers, widest first. */
735
+ _presentationCycle() {
736
+ const requested = Array.isArray(this.options.presentations) ? this.options.presentations : PRESENTATIONS;
737
+ // A strip that never names its modules has no 'expanded' apart from 'compact', and one that
738
+ // always names them under the tiles no 'compact' apart from 'expanded': offer only the one.
739
+ const same = this._moduleLabels === 'hidden' ? 'expanded' : this._moduleLabels === 'below' ? 'compact' : null;
740
+ const cycle = [...new Set(requested)].filter((name) => PRESENTATIONS.includes(name)
741
+ && (name !== 'compact' || this._modules) && name !== same);
742
+ return cycle.length >= 2 ? cycle : ['expanded', 'collapsed'];
743
+ }
744
+
745
+ /** @returns {'expanded'|'compact'|'collapsed'} */
746
+ _nextPresentation() {
747
+ const cycle = this._presentationCycle();
748
+ const index = cycle.indexOf(this.getPresentation());
749
+ return cycle[(index + 1) % cycle.length];
750
+ }
751
+
752
+ /**
753
+ * What the strip prints for the current state: `'auto'` names the modules beside their tiles
754
+ * while expanded and leaves the icons alone while collapsed.
755
+ * @returns {'hidden'|'below'|'beside'}
756
+ */
757
+ _stripLabels() {
758
+ const labels = this._moduleLabels;
759
+ if (labels === 'below') return 'below';
760
+ if (this.isCollapsed() || this._compact) return 'hidden';
761
+ return labels === 'hidden' ? 'hidden' : 'beside';
762
+ }
763
+
764
+ /** @returns {AppSidebarModule[]} The modules, read through any sections that group them. */
765
+ _moduleList() {
766
+ return /** @type {AppSidebarModule[]} */ (liftSections(this._items));
767
+ }
768
+
769
+ /** @param {string} panelId @returns {HTMLElement} */
770
+ _renderPanel(panelId) {
771
+ const titleId = uid('zx-app-sidebar-module');
772
+ const title = h('div', { class: 'zx-app-sidebar__panel-title', id: titleId });
773
+ const lead = h('div', { class: 'zx-app-sidebar__panel-lead' });
774
+ const list = h('ul', { class: 'zx-app-sidebar__list' });
775
+ const nav = h('nav', { class: 'zx-app-sidebar__nav', ariaLabelledby: titleId }, list);
776
+ const content = h('div', { class: 'zx-app-sidebar__panel-content' });
777
+ const footer = h('footer', { class: 'zx-app-sidebar__footer' });
778
+ appendSlot(footer, this.options.footer);
779
+ footer.hidden = !footer.hasChildNodes();
780
+ const panel = h('div', { class: 'zx-app-sidebar__panel', id: panelId },
781
+ h('header', { class: 'zx-app-sidebar__header' },
782
+ h('div', { class: 'zx-app-sidebar__header-content' }, title)),
783
+ h('div', { class: 'zx-app-sidebar__panel-body' }, lead, nav, content),
784
+ footer);
785
+ Object.assign(this.refs, {
786
+ expanded: panel, list,
787
+ panelTitle: title, panelLead: lead, panelContent: content, panelNav: nav
788
+ });
789
+ this._listenTree(list);
790
+ this._renderPanelContent();
791
+ return panel;
792
+ }
793
+
794
+ /** Puts the shown module's title, header, menu, and content into the panel. @returns {void} */
795
+ _renderPanelContent() {
796
+ const module = this._shownModule();
797
+ const { panelTitle, panelLead, panelContent, panelNav } = this.refs;
798
+ if (!panelTitle) return;
799
+ panelTitle.textContent = module?.label ?? '';
800
+ // Slot nodes already in place stay put, so a menu refresh never takes focus out of them.
801
+ placeSlots(panelLead, [this.options.header, module?.header]);
802
+ placeSlots(panelContent, [module?.content]);
803
+ /** @type {HTMLElement} */ (panelNav).hidden = !(module?.children?.length);
804
+ this._renderTree();
805
+ }
806
+
807
+ /**
808
+ * Shows a module's panel. Returns false when there is no such panel module or a listener
809
+ * prevented the switch.
810
+ * @param {string|number} id Module id.
811
+ * @param {{silent?: boolean}} [options={}]
812
+ * @returns {boolean}
813
+ */
814
+ _showModule(id, { silent = false } = {}) {
815
+ if (!this._modules) return false;
816
+ const module = this._moduleList().find((item) => String(item.id) === String(id));
817
+ if (!module || !isPanelModule(module) || module.disabled) return false;
818
+ if (String(module.id) === String(this._shownId)) return true;
819
+ const previous = this._shownId;
820
+ if (!silent && this.emit('modulechange', { item: module, id: module.id, previous }).defaultPrevented) return false;
821
+ this._shownId = module.id;
822
+ if (this._rail instanceof ModuleBar && !this.isCollapsed()) this._rail.setExpanded(module.id);
823
+ if (this.refs.list) {
824
+ this._renderPanelContent();
825
+ // Only the panel that just changed animates; a re-render for any other reason does not.
826
+ const body = this.refs.expanded?.querySelector('.zx-app-sidebar__panel-body');
827
+ if (body instanceof HTMLElement) {
828
+ delete body.dataset.moduleMotion;
829
+ void body.offsetWidth;
830
+ body.dataset.moduleMotion = 'enter';
831
+ }
832
+ }
833
+ return true;
834
+ }
835
+
836
+ /** @param {CustomEvent<AppNavSelectDetail>} event @param {boolean} expanded @returns {void} */
837
+ _onModuleSelect(event, expanded) {
838
+ const module = this._moduleList().find((item) => String(item.id) === String(event.detail.id));
839
+ if (module && isPanelModule(module)) {
840
+ // A module with a panel is not a destination: choosing it shows the panel.
841
+ event.preventDefault();
842
+ const shown = this._showModule(module.id);
843
+ if (shown && !expanded && this._orientation === 'vertical') {
844
+ this.expand();
845
+ visibleTreeControls(this.refs.list)[0]?.focus();
846
+ }
847
+ return;
848
+ }
849
+ const selected = this.emit('select', event.detail);
850
+ if (selected.defaultPrevented) event.preventDefault();
851
+ }
852
+
853
+ /** The arrow toward the panel shows a module's menu and moves into it. @param {KeyboardEvent} event @returns {void} */
854
+ _onStripKeydown(event) {
855
+ const into = this._side === 'right' ? 'ArrowLeft' : 'ArrowRight';
856
+ if (event.key !== into || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
857
+ const control = /** @type {Element} */ (event.target).closest?.('.zx-module-bar__item[data-app-nav-value-id]');
858
+ if (!control) return;
859
+ const module = this._moduleList().find((item) => String(item.id) === control.getAttribute('data-app-nav-value-id'));
860
+ if (!module || !isPanelModule(module) || !this._showModule(module.id)) return;
861
+ event.preventDefault();
862
+ const target = visibleTreeControls(this.refs.list)[0]
863
+ ?? this.refs.expanded?.querySelector('.zx-app-sidebar__panel-body :is(a[href], button, input, select, textarea, [tabindex="0"])');
864
+ if (target instanceof HTMLElement) target.focus();
865
+ }
866
+
867
+ /** @returns {AppSidebarModule|null} */
868
+ _shownModule() {
869
+ return this._moduleList().find((item) => String(item.id) === String(this._shownId)) ?? null;
870
+ }
871
+
872
+ /** @param {string|number|null|undefined} preferred @returns {string|number|null} */
873
+ _resolveShown(preferred) {
874
+ const panel = (id) => {
875
+ if (id == null) return null;
876
+ const module = this._moduleList().find((item) => String(item.id) === String(id));
877
+ return module && isPanelModule(module) ? module : null;
878
+ };
879
+ return panel(preferred)?.id
880
+ ?? panel(moduleOf(this._moduleList(), this._active)?.id)?.id
881
+ ?? this._moduleList().find(isPanelModule)?.id
882
+ ?? null;
883
+ }
884
+
885
+ /** @returns {AppNavItem[]} The items the tree draws: the shown module's menu in the modules layout. */
886
+ _treeItems() {
887
+ if (!this._modules) return this._items;
888
+ return this._shownModule()?.children ?? [];
889
+ }
890
+
371
891
  /**
372
892
  * Keeps a minimized rail's counts readable and audible.
373
893
  *
@@ -404,7 +924,7 @@ export class AppSidebar extends Component {
404
924
  ? /** @type {Element} */ (document.activeElement).closest('[data-app-nav-id]')
405
925
  ?.getAttribute('data-app-nav-id')
406
926
  : null;
407
- this.refs.list.replaceChildren(...this._items.map((item) => this._treeRow(item, 1)));
927
+ this.refs.list.replaceChildren(...this._treeItems().map((item) => this._treeRow(item, 1)));
408
928
  this._syncActive();
409
929
  if (focused !== null) {
410
930
  const replacement = [...this.refs.list.querySelectorAll('[data-app-nav-id]')]
@@ -415,6 +935,7 @@ export class AppSidebar extends Component {
415
935
 
416
936
  /** @param {AppNavItem} item @param {number} level @returns {HTMLElement} */
417
937
  _treeRow(item, level) {
938
+ if (item.section) return this._sectionRow(item, level);
418
939
  const children = item.children ?? [];
419
940
  const hasChildren = children.length > 0;
420
941
  const key = String(item.id);
@@ -442,6 +963,27 @@ export class AppSidebar extends Component {
442
963
  return row;
443
964
  }
444
965
 
966
+ /**
967
+ * A section: a heading over rows that stay at its own level. It names its list, so a screen
968
+ * reader hears "Records, list, 3 items" rather than a heading floating above unrelated rows.
969
+ * @param {AppNavItem} item Section.
970
+ * @param {number} level Level its rows take.
971
+ * @returns {HTMLElement}
972
+ */
973
+ _sectionRow(item, level) {
974
+ const labelId = uid('zx-app-sidebar-section');
975
+ const header = h('div', { class: 'zx-app-sidebar__section-header' },
976
+ h('span', { class: 'zx-app-sidebar__section-label', id: labelId }, item.label));
977
+ if (item.actions) {
978
+ const actions = h('span', { class: 'zx-app-sidebar__section-actions' });
979
+ appendSlot(actions, item.actions);
980
+ header.append(actions);
981
+ }
982
+ return h('li', { class: 'zx-app-sidebar__section' }, header,
983
+ h('ul', { class: 'zx-app-sidebar__section-list', ariaLabelledby: labelId },
984
+ (item.children ?? []).map((child) => this._treeRow(child, level))));
985
+ }
986
+
445
987
  /** @param {Event} event @returns {void} */
446
988
  _onTreeClick(event) {
447
989
  const control = /** @type {Element|null} */ (event.target)?.closest?.('[data-app-nav-id]');
@@ -479,6 +1021,16 @@ export class AppSidebar extends Component {
479
1021
  if (parentControl instanceof HTMLElement) {
480
1022
  event.preventDefault();
481
1023
  parentControl.focus();
1024
+ return;
1025
+ }
1026
+ // A top-level row of a module's menu: back out to that module's tile on the strip.
1027
+ if (this._modules && this._side === 'left' && this._rail instanceof ModuleBar) {
1028
+ const tile = this._rail._tiles().find((control) =>
1029
+ control.getAttribute('data-app-nav-value-id') === String(this._shownId));
1030
+ if (tile) {
1031
+ event.preventDefault();
1032
+ tile.focus();
1033
+ }
482
1034
  }
483
1035
  return;
484
1036
  }
@@ -561,12 +1113,38 @@ export class AppSidebar extends Component {
561
1113
  _reconcileExpanded() {
562
1114
  const branchIds = new Set();
563
1115
  walkItems(this._items, (item) => {
564
- if (item.children?.length) branchIds.add(String(item.id));
1116
+ if (item.children?.length && !item.section) branchIds.add(String(item.id));
565
1117
  });
566
1118
  for (const id of this._expanded) if (!branchIds.has(id)) this._expanded.delete(id);
567
1119
  }
568
1120
  }
569
1121
 
1122
+ /**
1123
+ * The pictogram of a presentation: a small window with its navigation drawn in it — the module
1124
+ * strip, wide when it is named, and the side menu when it shows. Built from boxes in the text
1125
+ * colour, so it follows the theme and forced colours and needs no icon set. Decorative.
1126
+ * @param {'expanded'|'compact'|'collapsed'} presentation
1127
+ * @returns {HTMLElement}
1128
+ */
1129
+ export function presentationGlyph(presentation) {
1130
+ return /** @type {HTMLElement} */ (h('span', {
1131
+ class: 'zx-app-sidebar__presentation-glyph',
1132
+ ariaHidden: 'true',
1133
+ dataset: { presentation }
1134
+ }, h('span', { class: 'zx-app-sidebar__presentation-strip' }),
1135
+ h('span', { class: 'zx-app-sidebar__presentation-panel' })));
1136
+ }
1137
+
1138
+ /**
1139
+ * Whether a module shows a panel — a menu, a header, or content of its own — rather than being a
1140
+ * destination itself.
1141
+ * @param {AppSidebarModule} module Top-level item.
1142
+ * @returns {boolean}
1143
+ */
1144
+ function isPanelModule(module) {
1145
+ return Boolean(module.children?.length || module.header || module.content);
1146
+ }
1147
+
570
1148
  /** @param {AppNavItem} item @param {boolean} disclosure @param {boolean} expanded @param {string|null} controls @param {Node|null} visual @returns {HTMLElement} */
571
1149
  function navControl(item, disclosure, expanded, controls, visual) {
572
1150
  const tag = item.href && !disclosure ? 'a' : 'button';
@@ -639,6 +1217,32 @@ function appendSlot(target, content) {
639
1217
  } else if (typeof content === 'object' && typeof content.nodeType === 'number') target.append(content);
640
1218
  }
641
1219
 
1220
+ /**
1221
+ * Fills a slot region with its contents unless exactly those nodes are already there, and hides
1222
+ * the region while it is empty.
1223
+ * @param {HTMLElement} target Slot region.
1224
+ * @param {unknown[]} contents Slot values, as `appendSlot()` takes them.
1225
+ * @returns {void}
1226
+ */
1227
+ function placeSlots(target, contents) {
1228
+ const staging = document.createDocumentFragment();
1229
+ const nodes = contents.flatMap((content) => {
1230
+ if (content == null) return [];
1231
+ if (typeof content === 'object' && typeof content.toElement === 'function') {
1232
+ const element = content.toElement();
1233
+ return element ? [element] : [];
1234
+ }
1235
+ if (typeof content === 'object' && typeof content.nodeType === 'number') return [content];
1236
+ appendSlot(staging, content);
1237
+ return [...staging.childNodes];
1238
+ });
1239
+ const current = [...target.childNodes];
1240
+ if (nodes.length !== current.length || nodes.some((node, index) => node !== current[index])) {
1241
+ target.replaceChildren(...nodes);
1242
+ }
1243
+ target.hidden = !target.hasChildNodes();
1244
+ }
1245
+
642
1246
  /** @param {'vertical'|'horizontal'} orientation @param {unknown} side @returns {'left'|'right'|'top'|'bottom'} */
643
1247
  function normalizeSide(orientation, side) {
644
1248
  if (orientation === 'horizontal') return side === 'bottom' ? 'bottom' : 'top';
@@ -661,3 +1265,5 @@ function copyItems(items) {
661
1265
 
662
1266
  /** Fired when expanded/minimized presentation changes. @event AppSidebar#collapsechange @type {CustomEvent<{collapsed: boolean}>} */
663
1267
  /** Fired when a minimized flyout changes. @event AppSidebar#flyoutchange @type {CustomEvent<{item: AppNavItem, id: string|number, open: boolean}>} */
1268
+ /** Fired when how much of the navigation shows changes. @event AppSidebar#presentationchange @type {CustomEvent<{presentation: 'expanded'|'compact'|'collapsed', previous: 'expanded'|'compact'|'collapsed'}>} */
1269
+ /** Fired before the modules layout's panel switches to another module; preventable. @event AppSidebar#modulechange @type {CustomEvent<{item: AppNavItem, id: string|number, previous: string|number|null}>} */