@zeyos/zx 4.4.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 (252) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/README.md +44 -11
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +31 -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 +293 -17
  7. package/dist/types/components/avatar/avatar.d.ts +46 -7
  8. package/dist/types/components/badge/badge.d.ts +21 -1
  9. package/dist/types/components/button/button.d.ts +15 -0
  10. package/dist/types/components/calendar/calendar.d.ts +67 -5
  11. package/dist/types/components/card-view/card-view.d.ts +7 -4
  12. package/dist/types/components/card-view/record-card.d.ts +11 -0
  13. package/dist/types/components/chart/chart.d.ts +10 -0
  14. package/dist/types/components/checklist/checklist.d.ts +9 -0
  15. package/dist/types/components/code-editor/code-editor.d.ts +6 -17
  16. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  17. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  18. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  19. package/dist/types/components/datebox/datebox.d.ts +7 -2
  20. package/dist/types/components/dialog/dialog.d.ts +46 -6
  21. package/dist/types/components/dropdown/dropdown.d.ts +24 -9
  22. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  23. package/dist/types/components/field/field.d.ts +54 -2
  24. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  25. package/dist/types/components/fieldset/fieldset.d.ts +26 -1
  26. package/dist/types/components/file-item/file-item.d.ts +34 -6
  27. package/dist/types/components/file-list/file-list.d.ts +35 -3
  28. package/dist/types/components/filter/filter.d.ts +127 -3
  29. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  30. package/dist/types/components/form/form.d.ts +12 -1
  31. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  32. package/dist/types/components/launcher/launcher.d.ts +268 -15
  33. package/dist/types/components/list-toolbar/list-toolbar.d.ts +16 -7
  34. package/dist/types/components/menu-button/menu-button.d.ts +39 -5
  35. package/dist/types/components/message/message.d.ts +132 -17
  36. package/dist/types/components/modal/modal.d.ts +43 -2
  37. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  38. package/dist/types/components/pagination/pagination.d.ts +17 -3
  39. package/dist/types/components/select/color.d.ts +12 -0
  40. package/dist/types/components/select/glyphs.d.ts +124 -0
  41. package/dist/types/components/select/select.d.ts +17 -3
  42. package/dist/types/components/sheet/sheet.d.ts +15 -0
  43. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  44. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  45. package/dist/types/components/table/table.d.ts +311 -18
  46. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  47. package/dist/types/components/tree/tree.d.ts +13 -0
  48. package/dist/types/components/view/record-view.d.ts +59 -2
  49. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  50. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  51. package/dist/types/core/breakpoint.d.ts +1 -1
  52. package/dist/types/core/component.d.ts +12 -2
  53. package/dist/types/core/i18n.d.ts +27 -3
  54. package/dist/types/core/icons.d.ts +23 -25
  55. package/dist/types/core/keyboard.d.ts +98 -1
  56. package/dist/types/core/messages.d.ts +382 -0
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +42 -1
  59. package/dist/types/core/overlay-stack.d.ts +260 -0
  60. package/dist/types/index.d.ts +10 -5
  61. package/dist/types/internal/app-rail.d.ts +38 -0
  62. package/dist/types/internal/menu-items.d.ts +71 -3
  63. package/dist/types/zeyos/connect.d.ts +6 -0
  64. package/dist/types/zeyos/form.d.ts +50 -9
  65. package/dist/types/zeyos/index.d.ts +2 -1
  66. package/dist/types/zeyos/launcher.d.ts +9 -0
  67. package/dist/types/zeyos/module-bar.d.ts +176 -0
  68. package/dist/types/zeyos/query.d.ts +110 -5
  69. package/dist/types/zeyos/schema.d.ts +24 -1
  70. package/dist/types/zeyos/table.d.ts +345 -22
  71. package/dist/types/zeyos/view.d.ts +26 -2
  72. package/dist/zx-zeyos.esm.js +6178 -1008
  73. package/dist/zx-zeyos.esm.min.js +53 -5
  74. package/dist/zx.css +2861 -827
  75. package/dist/zx.esm.js +8909 -1480
  76. package/dist/zx.esm.min.js +55 -7
  77. package/dist/zx.global.js +8911 -1482
  78. package/dist/zx.global.min.js +55 -7
  79. package/dist/zx.min.css +1 -1
  80. package/docs/api.json +1079 -117
  81. package/docs/llms.md +956 -154
  82. package/docs/llms.txt +9 -5
  83. package/package.json +1 -1
  84. package/src/components/account-menu/account-menu.css +35 -10
  85. package/src/components/account-menu/account-menu.js +12 -0
  86. package/src/components/activity-item/activity-item.css +1 -1
  87. package/src/components/activity-item/activity-item.js +3 -0
  88. package/src/components/activity-list/activity-list.css +38 -3
  89. package/src/components/activity-list/activity-list.js +61 -1
  90. package/src/components/app-icon/app-icon.css +14 -3
  91. package/src/components/app-icon/app-icon.js +11 -1
  92. package/src/components/app-rail/app-rail.css +59 -79
  93. package/src/components/app-sidebar/app-sidebar.css +304 -168
  94. package/src/components/app-sidebar/app-sidebar.js +667 -46
  95. package/src/components/aurora/aurora.css +5 -3
  96. package/src/components/avatar/avatar.css +32 -7
  97. package/src/components/avatar/avatar.js +72 -8
  98. package/src/components/badge/badge.css +54 -2
  99. package/src/components/badge/badge.js +29 -2
  100. package/src/components/breadcrumb/breadcrumb.css +1 -1
  101. package/src/components/button/button.css +67 -9
  102. package/src/components/button/button.js +38 -0
  103. package/src/components/calendar/calendar.css +47 -47
  104. package/src/components/calendar/calendar.js +26 -52
  105. package/src/components/card/card.css +4 -4
  106. package/src/components/card-view/card-view.css +14 -12
  107. package/src/components/card-view/card-view.js +13 -9
  108. package/src/components/card-view/record-card.js +14 -3
  109. package/src/components/chart/chart.css +4 -1
  110. package/src/components/chart/chart.js +12 -0
  111. package/src/components/check-button/check-button.css +10 -2
  112. package/src/components/checklist/checklist.css +9 -2
  113. package/src/components/checklist/checklist.js +11 -5
  114. package/src/components/checklist/field-adapter.js +3 -1
  115. package/src/components/code-editor/code-editor.css +7 -6
  116. package/src/components/code-editor/code-editor.js +19 -1
  117. package/src/components/code-editor/field-adapter.js +9 -2
  118. package/src/components/context-menu/context-menu.css +9 -4
  119. package/src/components/context-menu/context-menu.js +8 -2
  120. package/src/components/copy/copy-button.css +6 -0
  121. package/src/components/copy/copy-input.css +9 -4
  122. package/src/components/data-filter/data-filter.css +5 -4
  123. package/src/components/data-filter/data-filter.js +2 -2
  124. package/src/components/date-picker/date-picker.css +6 -6
  125. package/src/components/date-picker/date-range-picker.css +3 -3
  126. package/src/components/date-picker/time-picker.css +3 -3
  127. package/src/components/datebox/date-range-box.css +79 -2
  128. package/src/components/datebox/date-range-box.js +13 -2
  129. package/src/components/datebox/datebox.css +79 -2
  130. package/src/components/datebox/datebox.js +13 -2
  131. package/src/components/dialog/dialog.css +26 -0
  132. package/src/components/dialog/dialog.js +99 -17
  133. package/src/components/dock/dock.css +5 -4
  134. package/src/components/dropdown/dropdown.css +1 -1
  135. package/src/components/dropdown/dropdown.js +159 -72
  136. package/src/components/empty-state/empty-state.css +2 -2
  137. package/src/components/entity-ref/entity-ref.css +1 -1
  138. package/src/components/entity-ref/entity-ref.js +1 -1
  139. package/src/components/field/field.css +28 -9
  140. package/src/components/field/field.js +101 -29
  141. package/src/components/field-upload/field-upload.css +1 -1
  142. package/src/components/fieldset/external-adapter.js +363 -0
  143. package/src/components/fieldset/fieldset.css +5 -3
  144. package/src/components/fieldset/fieldset.js +38 -64
  145. package/src/components/file-item/file-item.js +144 -10
  146. package/src/components/file-list/file-list.js +144 -21
  147. package/src/components/filter/filter.css +86 -2
  148. package/src/components/filter/filter.js +307 -11
  149. package/src/components/filter-panel/filter-panel.css +1 -1
  150. package/src/components/filter-panel/filter-panel.js +149 -32
  151. package/src/components/finder/finder.css +16 -14
  152. package/src/components/form/form.css +5 -4
  153. package/src/components/form/form.js +11 -2
  154. package/src/components/grid/grid.css +1 -1
  155. package/src/components/groupbox/groupbox.css +1 -1
  156. package/src/components/kanban-view/kanban-card.js +11 -3
  157. package/src/components/kanban-view/kanban-view.css +42 -18
  158. package/src/components/kanban-view/kanban-view.js +9 -6
  159. package/src/components/launcher/launcher.css +108 -26
  160. package/src/components/launcher/launcher.js +530 -42
  161. package/src/components/list-toolbar/list-toolbar.css +13 -8
  162. package/src/components/list-toolbar/list-toolbar.js +40 -14
  163. package/src/components/master-panel/master-panel.css +2 -0
  164. package/src/components/master-panel/master-panel.js +0 -1
  165. package/src/components/menu-button/menu-button.css +72 -4
  166. package/src/components/menu-button/menu-button.js +160 -18
  167. package/src/components/message/message.css +33 -3
  168. package/src/components/message/message.js +264 -22
  169. package/src/components/modal/modal.css +1 -1
  170. package/src/components/modal/modal.js +83 -15
  171. package/src/components/module-bar/module-bar.css +486 -0
  172. package/src/components/module-bar/module-bar.js +1074 -0
  173. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  174. package/src/components/navigation-bar/navigation-bar.css +18 -9
  175. package/src/components/number-field/field-adapter.js +3 -1
  176. package/src/components/number-field/number-field.css +70 -0
  177. package/src/components/number-field/number-field.js +2 -0
  178. package/src/components/pagination/pagination.css +13 -11
  179. package/src/components/pagination/pagination.js +20 -4
  180. package/src/components/panel/panel.css +4 -3
  181. package/src/components/questionnaire/questionnaire.css +4 -4
  182. package/src/components/rating/field-adapter.js +2 -1
  183. package/src/components/rating/rating.css +8 -1
  184. package/src/components/search/search.css +4 -1
  185. package/src/components/select/color.js +22 -0
  186. package/src/components/select/field-adapter.js +2 -0
  187. package/src/components/select/glyphs.js +165 -0
  188. package/src/components/select/select.css +138 -45
  189. package/src/components/select/select.js +92 -47
  190. package/src/components/sheet/sheet.css +12 -9
  191. package/src/components/sheet/sheet.js +74 -32
  192. package/src/components/slider/field-adapter.js +6 -1
  193. package/src/components/slider/slider.css +18 -4
  194. package/src/components/slider/slider.js +1 -1
  195. package/src/components/sort-control/sort-control.css +4 -1
  196. package/src/components/split-view/split-view.css +1 -1
  197. package/src/components/stat-tile/stat-tile.css +42 -47
  198. package/src/components/stat-tile/stat-tile.js +17 -6
  199. package/src/components/stepper/stepper.css +1 -1
  200. package/src/components/tabbox/tabbox.css +41 -32
  201. package/src/components/tabbox/tabbox.js +5 -1
  202. package/src/components/table/table.css +187 -37
  203. package/src/components/table/table.js +718 -73
  204. package/src/components/tag-picker/field-adapter.js +2 -1
  205. package/src/components/tag-picker/tag-picker.css +83 -5
  206. package/src/components/tag-picker/tag-picker.js +25 -8
  207. package/src/components/timebox/timebox.css +22 -2
  208. package/src/components/toggle/toggle.css +12 -1
  209. package/src/components/toolbar/toolbar.js +2 -2
  210. package/src/components/tooltip/tooltip.css +4 -0
  211. package/src/components/tooltip/tooltip.js +44 -19
  212. package/src/components/tree/tree.css +26 -17
  213. package/src/components/tree/tree.js +31 -0
  214. package/src/components/value-list/value-list.css +3 -3
  215. package/src/components/view/record-view.css +1 -1
  216. package/src/components/view/record-view.js +178 -18
  217. package/src/components/view-settings/view-settings.css +285 -0
  218. package/src/components/view-settings/view-settings.js +1830 -0
  219. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  220. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  221. package/src/core/breakpoint.js +1 -1
  222. package/src/core/component.js +88 -17
  223. package/src/core/fontawesome.js +5 -1
  224. package/src/core/i18n.js +47 -4
  225. package/src/core/icons.js +204 -61
  226. package/src/core/keyboard.js +206 -11
  227. package/src/core/messages.js +427 -0
  228. package/src/core/motion.js +147 -0
  229. package/src/core/overlay-host.js +77 -3
  230. package/src/core/overlay-stack.js +487 -0
  231. package/src/core/position.js +22 -3
  232. package/src/index.js +10 -5
  233. package/src/internal/app-rail.js +135 -55
  234. package/src/internal/menu-items.js +61 -3
  235. package/src/zeyos/connect.js +6 -2
  236. package/src/zeyos/form.js +69 -27
  237. package/src/zeyos/index.js +4 -1
  238. package/src/zeyos/launcher.js +8 -3
  239. package/src/zeyos/module-bar.js +181 -0
  240. package/src/zeyos/query.js +197 -13
  241. package/src/zeyos/schema.js +144 -27
  242. package/src/zeyos/table.js +287 -84
  243. package/src/zeyos/view.js +49 -93
  244. package/styles/base.css +72 -1
  245. package/styles/icons.css +11 -1
  246. package/styles/print.css +1 -1
  247. package/styles/tokens/dark.css +117 -37
  248. package/styles/tokens/global.css +121 -27
  249. package/styles/tokens/semantic.css +140 -43
  250. package/styles/tokens/themes.css +7 -6
  251. package/styles/zx.css +3 -1
  252. package/styles/tokens/density.css +0 -9
@@ -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,
@@ -78,7 +127,9 @@ export class AppSidebar extends Component {
78
127
  /** @returns {HTMLElement} */
79
128
  render() {
80
129
  this._createdRoot = this.el === null;
81
- const root = /** @type {HTMLElement} */ (this.el ?? h('aside'));
130
+ // A plain box: the <nav> inside is the landmark. An <aside> here would add an unnamed
131
+ // complementary landmark around every navigation column.
132
+ const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
82
133
  this.el = root;
83
134
  this._original = this._createdRoot ? null : {
84
135
  attributes: Array.from(root.attributes, (attribute) => [attribute.name, attribute.value]),
@@ -91,6 +142,16 @@ export class AppSidebar extends Component {
91
142
  this._collapsed = Boolean(this.options.collapsed);
92
143
  this._orientation = this.options.orientation === 'horizontal' ? 'horizontal' : 'vertical';
93
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;
94
155
  this._modeAbort = null;
95
156
  root.replaceChildren();
96
157
  return root;
@@ -100,34 +161,131 @@ export class AppSidebar extends Component {
100
161
  setItems(items) {
101
162
  this._items = normalizeAppItems(items);
102
163
  this._reconcileExpanded();
103
- 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) {
104
170
  this._rail.setItems(this._items);
105
171
  this._decorateRail();
106
172
  } else this._renderTree();
107
173
  return this;
108
174
  }
109
175
 
110
- /** Returns a shallow copy of normalized items. @returns {AppNavItem[]} */
176
+ /**
177
+ * Returns a copy of the normalized items. A leaf has no `children` property, as it was written,
178
+ * rather than the empty list normalization gives it.
179
+ * @returns {AppNavItem[]}
180
+ */
111
181
  getItems() {
112
- return this._items.map((item) => ({
113
- ...item,
114
- children: item.children?.map((child) => ({ ...child }))
115
- }));
182
+ return copyItems(this._items);
116
183
  }
117
184
 
118
- /** 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
+ */
119
191
  setActive(id) {
120
192
  this._active = id;
121
193
  if (this._rail) this._rail.setActive(id);
122
- 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();
204
+ return this;
205
+ }
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();
123
224
  return this;
124
225
  }
125
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
+
126
284
  /** Replaces expanded branch ids. @param {Array<string|number>} ids @param {{silent?: boolean}} [options={}] @returns {this} */
127
285
  setExpanded(ids, { silent = false } = {}) {
128
286
  this._expanded = new Set(Array.isArray(ids) ? ids.map(String) : []);
129
287
  this._reconcileExpanded();
130
- if (!this._rail) this._renderTree();
288
+ if (this.refs.list) this._renderTree();
131
289
  if (!silent) this.emit('branchchange', {
132
290
  item: null,
133
291
  id: null,
@@ -149,12 +307,12 @@ export class AppSidebar extends Component {
149
307
  /** Toggles one inline branch. @param {string|number} id @param {{silent?: boolean}} [options={}] @returns {this} */
150
308
  toggleBranch(id, { silent = false } = {}) {
151
309
  const item = findItem(this._items, id);
152
- if (!item?.children?.length) return this;
310
+ if (!item?.children?.length || item.section) return this;
153
311
  const key = String(id);
154
312
  const expanded = !this._expanded.has(key);
155
313
  if (expanded) this._expanded.add(key);
156
314
  else this._expanded.delete(key);
157
- if (!this._rail) {
315
+ if (this.refs.list) {
158
316
  this._renderTree();
159
317
  if (expanded) this._markBranchMotion(key);
160
318
  }
@@ -179,19 +337,45 @@ export class AppSidebar extends Component {
179
337
 
180
338
  /** Sets the saved vertical expanded/minimized state. @param {boolean} collapsed @param {{silent?: boolean}} [options={}] @returns {this} */
181
339
  setCollapsed(collapsed, { silent = false } = {}) {
182
- 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() };
183
353
  const active = document.activeElement;
184
354
  const outgoingOwnedFocus = Boolean(active
185
355
  && (this.el.contains(active) || this._rail?._containsNode(active)));
186
- this._collapsed = Boolean(collapsed);
356
+ this._collapsed = collapsed;
357
+ this._compact = Boolean(compact);
187
358
  const next = this.isCollapsed();
188
- if (previous !== next) {
359
+ if (previous.collapsed !== next) {
189
360
  this._renderMode();
190
361
  if (outgoingOwnedFocus) {
191
- if (next) this._railExpandButton?.focus();
192
- 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 });
193
378
  }
194
- if (!silent) this.emit('collapsechange', { collapsed: next });
195
379
  }
196
380
  return this;
197
381
  }
@@ -259,7 +443,7 @@ export class AppSidebar extends Component {
259
443
  return this._rail?.isFlyoutOpen(id) ?? false;
260
444
  }
261
445
 
262
- /** 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} */
263
447
  focus() {
264
448
  if (this._rail) this._rail.focus();
265
449
  else visibleTreeControls(this.refs.list)[0]?.focus();
@@ -271,6 +455,7 @@ export class AppSidebar extends Component {
271
455
  const original = this._original;
272
456
  this._modeAbort?.abort();
273
457
  this._modeAbort = null;
458
+ for (const tooltip of this._stripTooltips.splice(0)) tooltip.destroy();
274
459
  this._rail?.destroy();
275
460
  this._rail = null;
276
461
  super.destroy();
@@ -285,15 +470,21 @@ export class AppSidebar extends Component {
285
470
  _renderMode() {
286
471
  this._modeAbort?.abort();
287
472
  this._modeAbort = new AbortController();
473
+ for (const tooltip of this._stripTooltips.splice(0)) tooltip.destroy();
474
+ this._presentationGroup = null;
288
475
  this._rail?.destroy();
289
476
  this._rail = null;
290
477
  this._railExpandButton = null;
291
- 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
+ }
292
481
  this.el.replaceChildren();
293
482
  this.el.dataset.orientation = this._orientation;
294
483
  this.el.dataset.side = this._side;
295
484
  this.el.dataset.collapsed = String(this.isCollapsed());
296
- 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();
297
488
  else this._renderExpanded();
298
489
  }
299
490
 
@@ -301,12 +492,7 @@ export class AppSidebar extends Component {
301
492
  _renderExpanded() {
302
493
  const headerContent = h('div', { class: 'zx-app-sidebar__header-content' });
303
494
  appendSlot(headerContent, this.options.header);
304
- const collapseButton = h('button', {
305
- class: 'zx-app-sidebar__collapse zx-icon-btn',
306
- type: 'button',
307
- ariaLabel: this._message('appSidebar.minimize', 'Minimize application sidebar')
308
- }, icon(this._side === 'right' ? 'chevron-right' : 'chevron-left'));
309
- collapseButton.hidden = !this.options.collapsible;
495
+ const collapseButton = this._collapseButton();
310
496
  const footer = h('footer', { class: 'zx-app-sidebar__footer' });
311
497
  appendSlot(footer, this.options.footer);
312
498
  footer.hidden = !footer.hasChildNodes();
@@ -319,10 +505,38 @@ export class AppSidebar extends Component {
319
505
  this.refs.list = list;
320
506
  this.refs.collapse = collapseButton;
321
507
  this.el.append(expanded);
322
- 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) {
323
538
  this._listenMode(list, 'click', (event) => this._onTreeClick(event));
324
539
  this._listenMode(list, 'keydown', (event) => this._onTreeKeydown(/** @type {KeyboardEvent} */ (event)));
325
- this._renderTree();
326
540
  }
327
541
 
328
542
  /** @returns {void} */
@@ -332,16 +546,7 @@ export class AppSidebar extends Component {
332
546
  this.refs.rail = railHost;
333
547
  const showToggle = this._orientation === 'vertical' && Boolean(this.options.collapsible);
334
548
  const railHeader = h('div', { class: 'zx-app-sidebar__rail-header' });
335
- if (showToggle) {
336
- const expandButton = h('button', {
337
- class: 'zx-app-sidebar__rail-toggle zx-icon-btn',
338
- type: 'button',
339
- ariaLabel: this._message('appSidebar.expand', 'Expand application sidebar')
340
- }, icon(this._side === 'right' ? 'chevron-left' : 'chevron-right'));
341
- railHeader.append(expandButton);
342
- this._railExpandButton = expandButton;
343
- this._listenMode(expandButton, 'click', () => this.expand());
344
- }
549
+ if (showToggle) railHeader.append(this._expandButton());
345
550
  appendSlot(railHeader, this.options.railHeader);
346
551
  this._rail = new MinimizedPresenter(railHost, {
347
552
  items: this._items,
@@ -365,6 +570,324 @@ export class AppSidebar extends Component {
365
570
  this._decorateRail();
366
571
  }
367
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
+
368
891
  /**
369
892
  * Keeps a minimized rail's counts readable and audible.
370
893
  *
@@ -401,7 +924,7 @@ export class AppSidebar extends Component {
401
924
  ? /** @type {Element} */ (document.activeElement).closest('[data-app-nav-id]')
402
925
  ?.getAttribute('data-app-nav-id')
403
926
  : null;
404
- 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)));
405
928
  this._syncActive();
406
929
  if (focused !== null) {
407
930
  const replacement = [...this.refs.list.querySelectorAll('[data-app-nav-id]')]
@@ -412,6 +935,7 @@ export class AppSidebar extends Component {
412
935
 
413
936
  /** @param {AppNavItem} item @param {number} level @returns {HTMLElement} */
414
937
  _treeRow(item, level) {
938
+ if (item.section) return this._sectionRow(item, level);
415
939
  const children = item.children ?? [];
416
940
  const hasChildren = children.length > 0;
417
941
  const key = String(item.id);
@@ -439,6 +963,27 @@ export class AppSidebar extends Component {
439
963
  return row;
440
964
  }
441
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
+
442
987
  /** @param {Event} event @returns {void} */
443
988
  _onTreeClick(event) {
444
989
  const control = /** @type {Element|null} */ (event.target)?.closest?.('[data-app-nav-id]');
@@ -476,6 +1021,16 @@ export class AppSidebar extends Component {
476
1021
  if (parentControl instanceof HTMLElement) {
477
1022
  event.preventDefault();
478
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
+ }
479
1034
  }
480
1035
  return;
481
1036
  }
@@ -558,12 +1113,38 @@ export class AppSidebar extends Component {
558
1113
  _reconcileExpanded() {
559
1114
  const branchIds = new Set();
560
1115
  walkItems(this._items, (item) => {
561
- if (item.children?.length) branchIds.add(String(item.id));
1116
+ if (item.children?.length && !item.section) branchIds.add(String(item.id));
562
1117
  });
563
1118
  for (const id of this._expanded) if (!branchIds.has(id)) this._expanded.delete(id);
564
1119
  }
565
1120
  }
566
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
+
567
1148
  /** @param {AppNavItem} item @param {boolean} disclosure @param {boolean} expanded @param {string|null} controls @param {Node|null} visual @returns {HTMLElement} */
568
1149
  function navControl(item, disclosure, expanded, controls, visual) {
569
1150
  const tag = item.href && !disclosure ? 'a' : 'button';
@@ -636,6 +1217,32 @@ function appendSlot(target, content) {
636
1217
  } else if (typeof content === 'object' && typeof content.nodeType === 'number') target.append(content);
637
1218
  }
638
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
+
639
1246
  /** @param {'vertical'|'horizontal'} orientation @param {unknown} side @returns {'left'|'right'|'top'|'bottom'} */
640
1247
  function normalizeSide(orientation, side) {
641
1248
  if (orientation === 'horizontal') return side === 'bottom' ? 'bottom' : 'top';
@@ -644,5 +1251,19 @@ function normalizeSide(orientation, side) {
644
1251
 
645
1252
  /** Fired when a destination/action is activated. @event AppSidebar#select @type {CustomEvent<AppNavSelectDetail>} */
646
1253
  /** Fired when an inline branch changes. @event AppSidebar#branchchange @type {CustomEvent<{item: AppNavItem|null, id: string|number|null, expanded: boolean|null, ids: Array<string|number>}>} */
1254
+ /**
1255
+ * Copies a navigation tree, leaving out the empty `children` lists normalization adds to leaves.
1256
+ * @param {AppNavItem[]} items Normalized items.
1257
+ * @returns {AppNavItem[]}
1258
+ */
1259
+ function copyItems(items) {
1260
+ return items.map((item) => {
1261
+ const { children, ...rest } = item;
1262
+ return children?.length ? { ...rest, children: copyItems(children) } : rest;
1263
+ });
1264
+ }
1265
+
647
1266
  /** Fired when expanded/minimized presentation changes. @event AppSidebar#collapsechange @type {CustomEvent<{collapsed: boolean}>} */
648
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}>} */