@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
@@ -0,0 +1,1074 @@
1
+ import { h } from '../../core/dom.js';
2
+ import { icon } from '../../core/icons.js';
3
+ import { isCssColor, uid } from '../../core/util.js';
4
+ import { AppRailPresenter, appNavIcon, normalizeAppItems } from '../../internal/app-rail.js';
5
+ import { APP_ICON_TONES, appIcon } from '../app-icon/app-icon.js';
6
+ import { Dropdown } from '../dropdown/dropdown.js';
7
+ import { Tooltip } from '../tooltip/tooltip.js';
8
+
9
+ /** @typedef {import('../../internal/app-rail.js').AppNavItem} AppNavItem */
10
+ /** @typedef {import('../../internal/app-rail.js').AppNavSelectDetail} AppNavSelectDetail */
11
+ /** @typedef {import('../app-icon/app-icon.js').AppIconTone} ModuleTone */
12
+ /**
13
+ * One module: an application-level destination or a module with a menu of its own.
14
+ * @typedef {Object} ModuleItem
15
+ * @property {string|number} id Stable module identifier.
16
+ * @property {string} label Module name: the tile's accessible name, tooltip, and catalogue label.
17
+ * @property {string|Node|{toElement: () => Node|null}|((context: Record<string, any>, item: AppNavItem) => Node|null)} [icon] Icon name, node, component, or factory.
18
+ * @property {ModuleTone} [tone] Identity colour from the theme's categorical tones.
19
+ * @property {string} [color] Identity colour as any CSS colour; takes precedence over `tone`.
20
+ * @property {string|number} [badge] Count or status badge.
21
+ * @property {boolean|number} [pinned] Shown on the bar, in this order when a number. When no
22
+ * module sets it, every module is on the bar; otherwise unpinned modules are in "All modules" only.
23
+ * @property {string} [group] Group heading in the "All modules" catalogue.
24
+ * @property {string} [description] Secondary text, shown as the catalogue entry's title.
25
+ * @property {unknown} [value] Emitted value; defaults to `id`.
26
+ * @property {string} [href] Native navigation target for a module without a menu.
27
+ * @property {string} [target] Native link browsing context.
28
+ * @property {() => void} [invoke] Application-owned non-link action.
29
+ * @property {boolean} [disabled] Whether activation is unavailable.
30
+ * @property {AppNavItem[]} [children] The module's menu. With `menus: 'flyout'` it opens beside
31
+ * the tile; it never renders inline.
32
+ */
33
+ /**
34
+ * @typedef {Object} ModuleBarOptions
35
+ * @property {ModuleItem[]} [items=[]] Modules, in bar order.
36
+ * @property {string|number|null} [active=null] Active destination id: a module, or any item in a module's menu.
37
+ * @property {'vertical'|'horizontal'} [orientation='vertical'] Bar flow.
38
+ * @property {'left'|'right'|'top'|'bottom'} [side='left'] Workspace edge the bar occupies.
39
+ * @property {'tint'|'solid'|'plain'} [appearance='tint'] Tile treatment: the glyph in the module
40
+ * colour on a tint of it, the solid identity tile, or monochrome glyphs.
41
+ * @property {'hidden'|'below'|'beside'} [labels='hidden'] Whether a vertical bar prints each
42
+ * module's name: under its tile (a narrow strip), beside it (a full navigation column, with the
43
+ * count at the end of the row), or not at all — the name then stays the accessible name and the
44
+ * tooltip. Horizontal bars never print names.
45
+ * @property {'flyout'|'none'} [menus='flyout'] How a module's children are presented: in a flyout,
46
+ * or by an adjacent panel the application owns, in which case activating the module emits `select`.
47
+ * @property {string|number|null} [expanded=null] With `menus: 'none'`, the module whose menu the
48
+ * adjacent panel shows.
49
+ * @property {string|null} [controls=null] With `menus: 'none'`, the id of that panel.
50
+ * @property {'auto'|'always'|'never'} [allModules='auto'] The "All modules" button: shown when a
51
+ * module does not fit or is unpinned, always, or never (the bar then scrolls).
52
+ * @property {Node|string|number|{toElement: () => Node|null}|null} [header=null] Leading content, such as a product mark.
53
+ * @property {Node|string|number|{toElement: () => Node|null}|null} [footer=null] Trailing content, such as an account menu.
54
+ * @property {string|null} [label=null] Navigation accessible label. Omitted resolves `moduleBar.label`, English `Modules`.
55
+ * @property {number} [openDelay=80] Flyout hover-open delay in milliseconds.
56
+ * @property {number} [closeDelay=160] Flyout pointer and focus crossing grace in milliseconds.
57
+ * @property {(item: AppNavItem, context: Record<string, any>) => Node|null} [renderIcon] Optional host icon renderer.
58
+ * @property {(event: CustomEvent<AppNavSelectDetail>) => void} [onselect] Preventable activation listener.
59
+ * @property {(event: CustomEvent<{item: AppNavItem, id: string|number, open: boolean}>) => void} [onflyoutchange] Flyout-state listener.
60
+ * @property {(event: CustomEvent<{visible: Array<string|number>, overflow: Array<string|number>}>) => void} [onoverflowchange] Overflow listener.
61
+ * @property {(event: CustomEvent<{open: boolean}>) => void} [onallmodules] "All modules" catalogue open-state listener.
62
+ */
63
+
64
+ const APPEARANCES = ['tint', 'solid', 'plain'];
65
+ /** Modules above which the catalogue offers a filter field. */
66
+ const FILTER_THRESHOLD = 12;
67
+ /** Milliseconds after which type-ahead starts a new search. */
68
+ const TYPEAHEAD_RESET = 600;
69
+
70
+ /**
71
+ * The module level of an application: one colour-coded tile per module in a vertical strip or a
72
+ * horizontal bar. A module with a menu opens it in a flyout, or leaves it to an adjacent panel
73
+ * (`menus: 'none'`, which is how `AppSidebar` composes the bar). Modules that do not fit, or are
74
+ * not pinned, are one step away in the "All modules" catalogue. The bar is one Tab stop: arrow
75
+ * keys, Home, End, and type-ahead move between tiles.
76
+ * @fires ModuleBar#select
77
+ * @fires ModuleBar#flyoutchange
78
+ * @fires ModuleBar#overflowchange
79
+ * @fires ModuleBar#allmodules
80
+ * @extends {AppRailPresenter}
81
+ */
82
+ export class ModuleBar extends AppRailPresenter {
83
+ static cssName = 'module-bar';
84
+
85
+ static partBlock = 'zx-module-bar';
86
+
87
+ /** @type {Readonly<ModuleBarOptions>} */
88
+ static defaults = {
89
+ items: [],
90
+ active: null,
91
+ orientation: 'vertical',
92
+ side: 'left',
93
+ appearance: 'tint',
94
+ labels: 'hidden',
95
+ menus: 'flyout',
96
+ expanded: null,
97
+ controls: null,
98
+ allModules: 'auto',
99
+ header: null,
100
+ footer: null,
101
+ label: null,
102
+ openDelay: 80,
103
+ closeDelay: 160,
104
+ renderIcon: null
105
+ };
106
+
107
+ /**
108
+ * Creates or enhances a module bar.
109
+ * @param {Element|string|null} [target=null] Bar target; a `<nav>` is created when null.
110
+ * @param {ModuleBarOptions} [options={}] Bar options.
111
+ */
112
+ constructor(target = null, options = {}) {
113
+ super(target, options);
114
+ }
115
+
116
+ /** @returns {HTMLElement} */
117
+ render() {
118
+ const root = /** @type {HTMLElement} */ (super.render());
119
+ this._sections = new Map();
120
+ this._items = liftSections(this._items, this._sections);
121
+ this._appearance = APPEARANCES.includes(this.options.appearance) ? this.options.appearance : 'tint';
122
+ this._menus = this.options.menus === 'none' ? 'none' : 'flyout';
123
+ this._expandedId = this.options.expanded ?? null;
124
+ this._labels = this.options.labels;
125
+ this._promoted = null;
126
+ this._catalogue = null;
127
+ this._silenceCatalogue = false;
128
+ this._catalogueTooltip = null;
129
+ this._observer = null;
130
+ this._partition = { visible: [], overflow: [] };
131
+ this._rovingId = null;
132
+ this._typeahead = { text: '', at: 0 };
133
+ root.setAttribute('aria-label', this._labelText());
134
+ root.dataset.appearance = this._appearance;
135
+ root.dataset.menus = this._menus;
136
+ root.dataset.allModules = ['always', 'never'].includes(this.options.allModules) ? this.options.allModules : 'auto';
137
+ root.dataset.labels = labelsMode(this.options.labels, root.dataset.orientation);
138
+ return root;
139
+ }
140
+
141
+ /** Replaces the modules and rebuilds the bar. @param {ModuleItem[]} items @returns {this} */
142
+ setItems(items) {
143
+ const sections = new Map();
144
+ const modules = liftSections(normalizeAppItems(items), sections);
145
+ this._sections = sections;
146
+ return super.setItems(modules);
147
+ }
148
+
149
+ /** Returns a copy of the normalized modules. @returns {ModuleItem[]} */
150
+ getItems() {
151
+ return /** @type {ModuleItem[]} */ (super.getItems());
152
+ }
153
+
154
+ /**
155
+ * Marks the active destination — a module, or anything in a module's menu — without routing.
156
+ * The module holding it becomes current and stays on the bar.
157
+ * @param {string|number|null} id Destination id.
158
+ * @returns {this}
159
+ */
160
+ setActive(id) {
161
+ return super.setActive(id);
162
+ }
163
+
164
+ /** Opens a module's menu flyout. @param {string|number} id Module id. @param {{focus?: boolean}} [options={}] Focus its first item. @returns {this} */
165
+ openFlyout(id, options = {}) {
166
+ return super.openFlyout(id, options);
167
+ }
168
+
169
+ /** Closes a module's menu flyout. @param {string|number} id Module id. @returns {this} */
170
+ closeFlyout(id) {
171
+ return super.closeFlyout(id);
172
+ }
173
+
174
+ /** Closes every menu flyout. @returns {this} */
175
+ closeAllFlyouts() {
176
+ return super.closeAllFlyouts();
177
+ }
178
+
179
+ /** Reports whether a module's menu flyout is open. @param {string|number} id Module id. @returns {boolean} */
180
+ isFlyoutOpen(id) {
181
+ return super.isFlyoutOpen(id);
182
+ }
183
+
184
+ /**
185
+ * With `menus: 'none'`, names the module whose menu the adjacent panel now shows.
186
+ * @param {string|number|null} id Module id, or null when the panel shows none.
187
+ * @returns {this}
188
+ */
189
+ setExpanded(id) {
190
+ this._expandedId = id ?? null;
191
+ this._syncExpanded();
192
+ this._layout();
193
+ return this;
194
+ }
195
+
196
+ /**
197
+ * Updates one badge in place — on the tile, in a flyout, and in the catalogue — without
198
+ * rebuilding the bar. `null` removes it.
199
+ * @param {string|number} id Module or menu item id.
200
+ * @param {string|number|null} badge New badge.
201
+ * @returns {this}
202
+ */
203
+ setBadge(id, badge) {
204
+ const item = findItem(this._items, id);
205
+ if (!item) return this;
206
+ if (badge == null || badge === '') delete item.badge;
207
+ else item.badge = badge;
208
+ const key = String(id);
209
+ for (const control of this.refs.list.querySelectorAll('.zx-module-bar__item[data-app-nav-value-id]')) {
210
+ if (control.getAttribute('data-app-nav-value-id') === key) this._applyTileBadge(control, item);
211
+ }
212
+ for (const flyout of this._flyouts.values()) {
213
+ for (const control of flyout.panel.querySelectorAll('[data-app-nav-value-id]')) {
214
+ if (control.getAttribute('data-app-nav-value-id') === key) applyRowBadge(control, item, 'zx-app-rail__flyout-item');
215
+ }
216
+ }
217
+ const entry = this._catalogueEntry(key);
218
+ if (entry) applyRowBadge(entry, item, 'zx-module-bar__entry');
219
+ return this;
220
+ }
221
+
222
+ /**
223
+ * Changes flow or edge, keeping items, the active destination, and the expanded module.
224
+ * @param {{orientation?: 'vertical'|'horizontal', side?: 'left'|'right'|'top'|'bottom'}} layout
225
+ * @returns {this}
226
+ */
227
+ setLayout(layout = {}) {
228
+ const orientation = layout.orientation === 'horizontal' || layout.orientation === 'vertical'
229
+ ? layout.orientation : this.el.dataset.orientation;
230
+ const sides = orientation === 'horizontal' ? ['top', 'bottom'] : ['left', 'right'];
231
+ const side = sides.includes(layout.side ?? this.el.dataset.side) ? (layout.side ?? this.el.dataset.side) : sides[0];
232
+ if (orientation === this.el.dataset.orientation && side === this.el.dataset.side) return this;
233
+ this.el.dataset.orientation = orientation;
234
+ this.el.dataset.side = side;
235
+ this.el.dataset.labels = labelsMode(this._labels, orientation);
236
+ this._rebuild();
237
+ return this;
238
+ }
239
+
240
+ /**
241
+ * Changes whether module names are printed — the expanded and collapsed states of a vertical
242
+ * navigation column — keeping items, the active destination, and the expanded module.
243
+ * @param {'hidden'|'below'|'beside'} labels
244
+ * @returns {this}
245
+ */
246
+ setLabels(labels) {
247
+ this._labels = labels;
248
+ const next = labelsMode(labels, this.el.dataset.orientation);
249
+ if (next === this.el.dataset.labels) return this;
250
+ const active = document.activeElement;
251
+ const focused = active instanceof HTMLElement && this.refs.list.contains(active)
252
+ && active.matches('.zx-module-bar__item') ? rovingKey(active) : null;
253
+ this.el.dataset.labels = next;
254
+ this._rebuild();
255
+ if (focused !== null) {
256
+ /** @type {HTMLElement[]} */ ([...this.refs.list.querySelectorAll('.zx-module-bar__item')])
257
+ .find((control) => rovingKey(control) === focused)?.focus();
258
+ }
259
+ return this;
260
+ }
261
+
262
+ /** Opens the "All modules" catalogue and focuses its filter or first entry. @returns {this} */
263
+ openAllModules() {
264
+ this._catalogue?.open();
265
+ this._focusCatalogue();
266
+ return this;
267
+ }
268
+
269
+ /** Closes the "All modules" catalogue. @returns {this} */
270
+ closeAllModules() {
271
+ this._catalogue?.close();
272
+ return this;
273
+ }
274
+
275
+ /** Reports whether the "All modules" catalogue is open. @returns {boolean} */
276
+ isAllModulesOpen() {
277
+ return this._catalogue?.isOpen() ?? false;
278
+ }
279
+
280
+ /** Focuses the bar's Tab stop: the current module, else the first tile. @returns {this} */
281
+ focus() {
282
+ this._rovingStop()?.focus();
283
+ return this;
284
+ }
285
+
286
+ /** Destroys flyouts, tooltips, and the catalogue, and restores or removes the root. @returns {void} */
287
+ destroy() {
288
+ super.destroy();
289
+ }
290
+
291
+ /** @param {Node|null} node @returns {boolean} @private */
292
+ _containsNode(node) {
293
+ return super._containsNode(node) || Boolean(node && this._catalogue?.getPanel().contains(node));
294
+ }
295
+
296
+ /** @returns {void} */
297
+ _clearArtifacts() {
298
+ super._clearArtifacts();
299
+ this._observer?.disconnect();
300
+ this._observer = null;
301
+ this._catalogueTooltip?.destroy();
302
+ this._catalogueTooltip = null;
303
+ // A catalogue torn down with the bar is not a reader closing it.
304
+ this._silenceCatalogue = true;
305
+ this._catalogue?.destroy();
306
+ this._silenceCatalogue = false;
307
+ this._catalogue = null;
308
+ }
309
+
310
+ /** @param {AppNavItem} item @returns {boolean} */
311
+ _flyoutsFor(item) {
312
+ return this._menus === 'flyout' && !item.disabled;
313
+ }
314
+
315
+ /** @returns {boolean} */
316
+ _tooltipFor() {
317
+ return this.el.dataset.labels === 'hidden';
318
+ }
319
+
320
+ /** @param {AppNavItem} item @returns {HTMLElement} */
321
+ _renderControl(item) {
322
+ const menu = Boolean(item.children?.length);
323
+ const tag = item.href && !menu ? 'a' : 'button';
324
+ const named = this.el.dataset.labels !== 'hidden';
325
+ const control = /** @type {HTMLElement} */ (h(tag, {
326
+ class: 'zx-module-bar__item',
327
+ type: tag === 'button' ? 'button' : null,
328
+ href: tag === 'a' ? item.href : null,
329
+ target: tag === 'a' ? item.target ?? null : null,
330
+ rel: tag === 'a' && item.target ? 'noopener' : null,
331
+ ariaDisabled: item.disabled ? 'true' : null,
332
+ tabIndex: -1,
333
+ dataset: { appNavId: String(item.id), appNavValueId: String(item.id) }
334
+ },
335
+ this._renderTile(item, 'bar'),
336
+ named ? h('span', { class: 'zx-module-bar__label' }, item.label) : null));
337
+ this._applyTileBadge(control, item);
338
+ return control;
339
+ }
340
+
341
+ /** @returns {void} */
342
+ _afterRebuild() {
343
+ const list = this.refs.list;
344
+ const order = barOrder(this._items);
345
+ const rows = new Map();
346
+ for (const row of [...list.children]) {
347
+ const id = row.querySelector('[data-app-nav-value-id]')?.getAttribute('data-app-nav-value-id');
348
+ if (id == null) continue;
349
+ row.dataset.moduleId = id;
350
+ rows.set(id, row);
351
+ }
352
+ // Bar order puts pinned modules first, in their pinned order; rows are moved once, here. A
353
+ // section heads the first of its modules; on an icon strip it is drawn as a rule.
354
+ let section = null;
355
+ for (const item of order) {
356
+ const row = rows.get(String(item.id));
357
+ if (!row) continue;
358
+ const label = this._sections.get(String(item.id)) ?? null;
359
+ if (label !== null && label !== section) {
360
+ list.append(h('li', { class: 'zx-module-bar__heading', dataset: { section: label } },
361
+ h('span', { class: 'zx-module-bar__heading-label' }, label)));
362
+ }
363
+ section = label;
364
+ list.append(row);
365
+ }
366
+ if (this._promoted !== null && !findItem(this._items, this._promoted)) this._promoted = null;
367
+ this._buildCatalogue();
368
+ this._interaction.listen(list, 'focusin', (event) => {
369
+ const control = /** @type {Element} */ (event.target).closest?.('.zx-module-bar__item');
370
+ if (control instanceof HTMLElement) this._setRovingStop(control);
371
+ });
372
+ this._interaction.listen(list, 'focusout', (event) => {
373
+ if (!list.contains(/** @type {Node|null} */ (event.relatedTarget))) {
374
+ this._rovingId = null;
375
+ this._syncRoving();
376
+ }
377
+ });
378
+ if (typeof ResizeObserver === 'function') {
379
+ this._observer = new ResizeObserver(() => this._layout());
380
+ this._observer.observe(list);
381
+ }
382
+ }
383
+
384
+ /** @returns {void} */
385
+ _syncActive() {
386
+ super._syncActive();
387
+ const activeId = this._active == null ? null : String(this._active);
388
+ for (const control of this._tiles()) {
389
+ const item = findItem(this._items, control.getAttribute('data-app-nav-value-id'));
390
+ if (!item) continue;
391
+ const state = currentState(item, activeId);
392
+ if (state) control.setAttribute('aria-current', state);
393
+ else control.removeAttribute('aria-current');
394
+ if (state === 'true') control.dataset.activeDescendant = 'true';
395
+ else delete control.dataset.activeDescendant;
396
+ }
397
+ for (const entry of this._catalogueEntries()) {
398
+ const item = findItem(this._items, entry.dataset.moduleId);
399
+ const state = item ? currentState(item, activeId) : null;
400
+ if (state) entry.setAttribute('aria-current', state);
401
+ else entry.removeAttribute('aria-current');
402
+ }
403
+ this._syncExpanded();
404
+ this._layout();
405
+ }
406
+
407
+ /**
408
+ * Shows the tiles that fit, in bar order, and moves the rest behind "All modules". Everything is
409
+ * shown first so a bar sized by its content measures as not overflowing and never shrinks itself.
410
+ * @returns {void}
411
+ */
412
+ _layout() {
413
+ const list = this.refs.list;
414
+ if (!list || !this._items) return;
415
+ const rows = [...list.querySelectorAll(':scope > [data-module-id]')];
416
+ const moreRow = list.querySelector(':scope > [data-module-bar-more]');
417
+ const mode = this.el.dataset.allModules;
418
+ const keep = [moduleOf(this._items, this._active)?.id, this._expandedId, this._promoted];
419
+ let capacity = Infinity;
420
+ if (mode !== 'never' && list.isConnected) {
421
+ for (const row of rows) row.hidden = false;
422
+ if (moreRow) moreRow.hidden = false;
423
+ syncHeadings(list);
424
+ capacity = measureCapacity(list, this.el.dataset.orientation === 'horizontal', rows.length + 1);
425
+ }
426
+ // An always-shown button takes its slot whether or not anything overflows.
427
+ const partition = partitionModules(this._items, capacity, keep, mode === 'always');
428
+ const visible = new Set(partition.visible.map((item) => String(item.id)));
429
+ for (const row of rows) row.hidden = !visible.has(row.dataset.moduleId);
430
+ syncHeadings(list);
431
+ if (moreRow) moreRow.hidden = mode === 'never' || (mode === 'auto' && !partition.more);
432
+ const ids = {
433
+ visible: partition.visible.map((item) => item.id),
434
+ overflow: partition.overflow.map((item) => item.id)
435
+ };
436
+ const changed = ids.visible.join('\u0000') !== this._partition.visible.join('\u0000')
437
+ || ids.overflow.join('\u0000') !== this._partition.overflow.join('\u0000');
438
+ this._partition = ids;
439
+ this._syncRoving();
440
+ // Before the bar is in a document nothing has been measured, so nothing has changed yet.
441
+ if (changed && list.isConnected) this.emit('overflowchange', { ...ids });
442
+ }
443
+
444
+ /** @param {AppNavItem} item @param {'bar'|'catalogue'} location @returns {HTMLElement} */
445
+ _renderTile(item, location) {
446
+ const visual = appNavIcon(item, this.options.renderIcon, {
447
+ orientation: this.el.dataset.orientation,
448
+ collapsed: true,
449
+ side: this.el.dataset.side,
450
+ level: 1,
451
+ location: location === 'bar' ? 'module' : 'catalogue'
452
+ }) ?? h('span', { class: 'zx-module-bar__initial' }, item.label.trim().charAt(0).toUpperCase());
453
+ const { tone, color } = resolveModuleColor(item);
454
+ const tile = h('span', {
455
+ class: 'zx-module-bar__tile',
456
+ ariaHidden: 'true',
457
+ dataset: { appearance: this._appearance, ...(tone ? { tone } : {}), ...(color ? { color: '' } : {}) }
458
+ });
459
+ if (this._appearance === 'solid') {
460
+ tile.append(appIcon({
461
+ icon: visual,
462
+ color,
463
+ tone: tone ?? (color ? null : 'slate'),
464
+ size: 'var(--zx-module-bar-tile)',
465
+ iconSize: '58%',
466
+ glass: 'subtle'
467
+ }));
468
+ } else {
469
+ if (color) tile.style.setProperty('--zx-module-bar-color', color);
470
+ tile.append(visual);
471
+ }
472
+ return tile;
473
+ }
474
+
475
+ /** @param {HTMLElement} control @param {AppNavItem} item @returns {void} */
476
+ _applyTileBadge(control, item) {
477
+ control.querySelector(':scope > .zx-module-bar__badge')?.remove();
478
+ const count = item.badge == null || item.badge === '' ? null : String(item.badge);
479
+ control.setAttribute('aria-label', count === null ? item.label
480
+ : this._message('moduleBar.itemBadge', '%1 (%2)', item.label, count));
481
+ if (count === null) return;
482
+ control.append(h('span', {
483
+ class: 'zx-module-bar__badge',
484
+ ariaHidden: 'true',
485
+ // A three-digit pill on a tile stops being a badge and starts being a label: past two
486
+ // characters it is drawn as a dot, and the figure lives on in the accessible name. A row
487
+ // with its name printed beside the tile has room for the whole figure.
488
+ dataset: { zxBadge: count.length > 2 && this.el.dataset.labels !== 'beside' ? 'dot' : 'count' }
489
+ }, count));
490
+ }
491
+
492
+ /** Builds the "All modules" button and its catalogue for this render. @returns {void} */
493
+ _buildCatalogue() {
494
+ const label = this._message('moduleBar.more', 'All modules');
495
+ const named = this.el.dataset.labels !== 'hidden';
496
+ const button = h('button', {
497
+ class: 'zx-module-bar__item',
498
+ type: 'button',
499
+ ariaLabel: label,
500
+ tabIndex: -1,
501
+ dataset: { moduleBarMore: '' }
502
+ },
503
+ h('span', { class: 'zx-module-bar__tile', ariaHidden: 'true', dataset: { appearance: 'plain' } }, icon('grid')),
504
+ named ? h('span', { class: 'zx-module-bar__label' }, label) : null);
505
+ this.refs.list.append(h('li', { class: 'zx-module-bar__row', dataset: { moduleBarMore: '' } }, button));
506
+ if (!named) {
507
+ this._catalogueTooltip = new Tooltip(button, {
508
+ content: label,
509
+ placement: /** @type {any} */ ({ left: 'right', right: 'left', top: 'bottom', bottom: 'top' }[this.el.dataset.side] ?? 'right'),
510
+ delay: 450
511
+ });
512
+ }
513
+
514
+ const filter = this._items.length > FILTER_THRESHOLD ? h('input', {
515
+ class: 'zx-module-bar__filter',
516
+ type: 'search',
517
+ autocomplete: 'off',
518
+ placeholder: this._message('moduleBar.filter', 'Filter modules'),
519
+ ariaLabel: this._message('moduleBar.filter', 'Filter modules')
520
+ }) : null;
521
+ const groups = new Map();
522
+ for (const item of this._items) {
523
+ const name = typeof item.group === 'string' ? item.group : this._sections.get(String(item.id)) ?? '';
524
+ if (!groups.has(name)) groups.set(name, []);
525
+ groups.get(name).push(item);
526
+ }
527
+ const sections = [...groups].map(([name, members]) => {
528
+ const headingId = name ? uid('zx-module-bar-group') : null;
529
+ return h('div', { class: 'zx-module-bar__group' },
530
+ name ? h('div', { class: 'zx-module-bar__group-label', id: headingId }, name) : null,
531
+ h('ul', { class: 'zx-module-bar__grid', ariaLabelledby: headingId },
532
+ members.map((item) => h('li', { dataset: { moduleId: String(item.id) } }, this._catalogueControl(item)))));
533
+ });
534
+ const empty = h('p', { class: 'zx-module-bar__empty', hidden: true },
535
+ this._message('moduleBar.empty', 'No modules'));
536
+ const content = h('div', { class: 'zx-module-bar__catalogue' }, filter, ...sections, empty);
537
+ const placement = /** @type {any} */ ({
538
+ left: 'right-start', right: 'left-start', top: 'bottom-end', bottom: 'top-end'
539
+ }[this.el.dataset.side] ?? 'right-start');
540
+ const dropdown = new Dropdown(button, { content, placement, offset: 6 });
541
+ const panel = dropdown.getPanel();
542
+ panel.classList.add('zx-module-bar__popover');
543
+ panel.setAttribute('role', 'region');
544
+ panel.setAttribute('aria-label', label);
545
+ this._catalogue = dropdown;
546
+ dropdown.on('open', () => {
547
+ this.closeAllFlyouts();
548
+ this._catalogueTooltip?.hide();
549
+ this.emit('allmodules', { open: true });
550
+ });
551
+ dropdown.on('close', () => {
552
+ if (filter instanceof HTMLInputElement && filter.value) {
553
+ filter.value = '';
554
+ this._filterCatalogue('');
555
+ }
556
+ if (!this._silenceCatalogue) this.emit('allmodules', { open: false });
557
+ });
558
+ this._interaction.listen(button, 'click', () => {
559
+ if (dropdown.isOpen()) this._focusCatalogue();
560
+ });
561
+ if (filter) {
562
+ this._interaction.listen(filter, 'input', () => this._filterCatalogue(/** @type {HTMLInputElement} */ (filter).value));
563
+ }
564
+ this._interaction.listen(panel, 'click', (event) => this._onCatalogueClick(event));
565
+ // Tabbing out of the catalogue closes it, as clicking outside does.
566
+ this._interaction.listen(panel, 'focusout', (event) => {
567
+ const next = /** @type {Node|null} */ (event.relatedTarget);
568
+ if (next && !panel.contains(next) && next !== button) dropdown.close();
569
+ });
570
+ this._interaction.listen(panel, 'keydown', (event) => this._onCatalogueKeydown(/** @type {KeyboardEvent} */ (event)));
571
+ // The entries are one Tab stop, the one last focused: arrows move inside the grid.
572
+ this._interaction.listen(panel, 'focusin', (event) => {
573
+ const entry = /** @type {Element} */ (event.target).closest?.('.zx-module-bar__entry');
574
+ if (entry instanceof HTMLElement) this._syncCatalogueStop(entry);
575
+ });
576
+ this._syncCatalogueStop();
577
+ }
578
+
579
+ /**
580
+ * Leave exactly one visible catalogue entry in the Tab order: `preferred`, else the current stop,
581
+ * else the current module, else the first entry the filter shows.
582
+ * @param {HTMLElement} [preferred]
583
+ * @returns {void}
584
+ */
585
+ _syncCatalogueStop(preferred) {
586
+ const all = this._catalogueEntries();
587
+ const shown = all.filter((entry) => !entry.closest('li')?.hidden);
588
+ const stop = (preferred && shown.includes(preferred) ? preferred : null)
589
+ ?? shown.find((entry) => entry.tabIndex === 0 && entry.dataset.catalogueStop === 'true')
590
+ ?? shown.find((entry) => entry.hasAttribute('aria-current')) ?? shown[0];
591
+ for (const entry of all) {
592
+ entry.tabIndex = entry === stop ? 0 : -1;
593
+ if (entry === stop) entry.dataset.catalogueStop = 'true';
594
+ else delete entry.dataset.catalogueStop;
595
+ }
596
+ }
597
+
598
+ /** @param {AppNavItem} item @returns {HTMLElement} */
599
+ _catalogueControl(item) {
600
+ const menu = Boolean(item.children?.length);
601
+ const tag = item.href && !menu ? 'a' : 'button';
602
+ const control = /** @type {HTMLElement} */ (h(tag, {
603
+ class: 'zx-module-bar__entry',
604
+ type: tag === 'button' ? 'button' : null,
605
+ href: tag === 'a' ? item.href : null,
606
+ target: tag === 'a' ? item.target ?? null : null,
607
+ rel: tag === 'a' && item.target ? 'noopener' : null,
608
+ title: typeof item.description === 'string' ? item.description : null,
609
+ ariaDisabled: item.disabled ? 'true' : null,
610
+ dataset: { moduleId: String(item.id) }
611
+ },
612
+ this._renderTile(item, 'catalogue'),
613
+ h('span', { class: 'zx-module-bar__entry-label' }, item.label)));
614
+ applyRowBadge(control, item, 'zx-module-bar__entry');
615
+ return control;
616
+ }
617
+
618
+ /** @param {MouseEvent|Event} event @returns {void} */
619
+ _onCatalogueClick(event) {
620
+ const control = /** @type {Element} */ (event.target).closest?.('.zx-module-bar__entry');
621
+ if (!(control instanceof HTMLElement)) return;
622
+ const item = findItem(this._items, control.dataset.moduleId);
623
+ if (!item || item.disabled) {
624
+ event.preventDefault();
625
+ return;
626
+ }
627
+ if (item.children?.length && this._menus === 'flyout') {
628
+ // A module with a menu is not a destination: it joins the bar for now and opens its menu.
629
+ event.preventDefault();
630
+ this._catalogue?.close();
631
+ this._promoted = item.id;
632
+ this._layout();
633
+ const keyboard = event instanceof MouseEvent && event.detail === 0;
634
+ this.openFlyout(item.id, { focus: keyboard });
635
+ if (!keyboard) this._tiles().find((tile) => tile.getAttribute('data-app-nav-value-id') === String(item.id))?.focus();
636
+ return;
637
+ }
638
+ // Choosing a module ends the catalogue's job whether or not a listener took over the routing.
639
+ this._select(item, event);
640
+ const panel = this._catalogue?.getPanel();
641
+ this._catalogue?.close();
642
+ // Focus left behind in the hidden catalogue goes to the module, which the bar now shows, or
643
+ // back to the button that opened it.
644
+ const active = document.activeElement;
645
+ if (!active || active === document.body || panel?.contains(active)) {
646
+ const tile = this._tiles().find((control) => control.getAttribute('data-app-nav-value-id') === String(item.id)
647
+ && !control.closest('li')?.hidden);
648
+ (tile ?? this.refs.list.querySelector('[data-module-bar-more] .zx-module-bar__item'))?.focus();
649
+ }
650
+ }
651
+
652
+ /** @param {KeyboardEvent} event @returns {void} */
653
+ _onCatalogueKeydown(event) {
654
+ const panel = this._catalogue?.getPanel();
655
+ if (!panel || event.altKey || event.ctrlKey || event.metaKey) return;
656
+ const filter = panel.querySelector('.zx-module-bar__filter');
657
+ const entries = this._catalogueEntries().filter((entry) => !entry.closest('li')?.hidden);
658
+ const current = /** @type {Element} */ (event.target).closest?.('.zx-module-bar__entry');
659
+ if (event.target === filter) {
660
+ if (event.key === 'ArrowDown' && entries[0]) {
661
+ event.preventDefault();
662
+ entries[0].focus();
663
+ }
664
+ return;
665
+ }
666
+ if (!(current instanceof HTMLElement)) return;
667
+ const index = entries.indexOf(current);
668
+ let next = null;
669
+ if (event.key === 'ArrowRight') next = entries[Math.min(entries.length - 1, index + 1)];
670
+ else if (event.key === 'ArrowLeft') next = entries[Math.max(0, index - 1)];
671
+ else if (event.key === 'Home') next = entries[0];
672
+ else if (event.key === 'End') next = entries.at(-1);
673
+ else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
674
+ next = nearestInRow(entries, current, event.key === 'ArrowDown' ? 1 : -1);
675
+ if (!next && event.key === 'ArrowUp' && filter instanceof HTMLElement) next = filter;
676
+ } else if (filter instanceof HTMLElement && event.key.length === 1 && event.key !== ' ') {
677
+ // Typing anywhere in the catalogue filters it: the character lands in the field.
678
+ filter.focus();
679
+ return;
680
+ } else return;
681
+ event.preventDefault();
682
+ next?.focus();
683
+ }
684
+
685
+ /** @param {string} query @returns {void} */
686
+ _filterCatalogue(query) {
687
+ const panel = this._catalogue?.getPanel();
688
+ if (!panel) return;
689
+ const needle = query.trim().toLocaleLowerCase();
690
+ let shown = 0;
691
+ for (const group of panel.querySelectorAll('.zx-module-bar__group')) {
692
+ let groupShown = 0;
693
+ for (const row of group.querySelectorAll('li[data-module-id]')) {
694
+ const item = findItem(this._items, /** @type {HTMLElement} */ (row).dataset.moduleId);
695
+ const haystack = `${item?.label ?? ''} ${item?.description ?? ''} ${item?.group ?? ''}`.toLocaleLowerCase();
696
+ const match = !needle || haystack.includes(needle);
697
+ /** @type {HTMLElement} */ (row).hidden = !match;
698
+ if (match) groupShown += 1;
699
+ }
700
+ /** @type {HTMLElement} */ (group).hidden = groupShown === 0;
701
+ shown += groupShown;
702
+ }
703
+ const empty = panel.querySelector('.zx-module-bar__empty');
704
+ if (empty instanceof HTMLElement) empty.hidden = shown > 0;
705
+ this._syncCatalogueStop();
706
+ }
707
+
708
+ /** @returns {void} */
709
+ _focusCatalogue() {
710
+ const panel = this._catalogue?.getPanel();
711
+ if (!panel || !this._catalogue?.isOpen()) return;
712
+ const filter = panel.querySelector('.zx-module-bar__filter');
713
+ const target = filter ?? this._catalogueEntries().find((entry) => entry.hasAttribute('aria-current'))
714
+ ?? this._catalogueEntries()[0];
715
+ if (target instanceof HTMLElement) target.focus();
716
+ }
717
+
718
+ /** @param {KeyboardEvent} event @returns {void} */
719
+ _onRailKeydown(event) {
720
+ const control = /** @type {Element} */ (event.target).closest?.('.zx-module-bar__item');
721
+ if (!(control instanceof HTMLElement) || !this.refs.list.contains(control)) return;
722
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
723
+ const controls = this._rovingControls();
724
+ const index = controls.indexOf(control);
725
+ const horizontal = this.el.dataset.orientation === 'horizontal';
726
+ const previous = horizontal ? 'ArrowLeft' : 'ArrowUp';
727
+ const next = horizontal ? 'ArrowRight' : 'ArrowDown';
728
+ const into = /** @type {Record<string, string>} */ ({
729
+ left: 'ArrowRight', right: 'ArrowLeft', top: 'ArrowDown', bottom: 'ArrowUp'
730
+ })[this.el.dataset.side];
731
+ const id = control.getAttribute('data-app-nav-id');
732
+ if (event.key === into && id !== null && this._flyouts.has(id)) {
733
+ event.preventDefault();
734
+ this.openFlyout(id, { focus: true });
735
+ return;
736
+ }
737
+ let target = null;
738
+ if (event.key === 'Home') target = controls[0];
739
+ else if (event.key === 'End') target = controls.at(-1);
740
+ else if (event.key === previous) target = controls[(index - 1 + controls.length) % controls.length];
741
+ else if (event.key === next) target = controls[(index + 1) % controls.length];
742
+ else if (event.key.length === 1 && event.key !== ' ') target = this._typeaheadTarget(event.key, controls, index);
743
+ else return;
744
+ if (!target) return;
745
+ event.preventDefault();
746
+ // The Tab stop moves with the key, not only with the focus event a background window withholds.
747
+ this._setRovingStop(target);
748
+ target.focus();
749
+ }
750
+
751
+ /** @param {string} key @param {HTMLElement[]} controls @param {number} index @returns {HTMLElement|null} */
752
+ _typeaheadTarget(key, controls, index) {
753
+ const now = Date.now();
754
+ const text = now - this._typeahead.at > TYPEAHEAD_RESET ? key : this._typeahead.text + key;
755
+ this._typeahead = { text, at: now };
756
+ const needle = text.toLocaleLowerCase();
757
+ // Repeating one letter cycles through the modules that start with it.
758
+ const repeated = [...needle].every((character) => character === needle[0]);
759
+ const labelOf = (control) => (findItem(this._items, control.getAttribute('data-app-nav-value-id'))?.label
760
+ ?? control.getAttribute('aria-label') ?? '').toLocaleLowerCase();
761
+ const ordered = [...controls.slice(index + 1), ...controls.slice(0, index + 1)];
762
+ const match = ordered.find((control) => labelOf(control).startsWith(needle))
763
+ ?? (repeated ? ordered.find((control) => labelOf(control).startsWith(needle[0])) : null);
764
+ if (match || text.length === 1) return match ?? null;
765
+ // Nothing starts with the whole run: the key starts a new search of its own.
766
+ this._typeahead = { text: key, at: now };
767
+ return ordered.find((control) => labelOf(control).startsWith(key.toLocaleLowerCase())) ?? null;
768
+ }
769
+
770
+ /** @returns {HTMLElement[]} Every module tile, shown or not. */
771
+ _tiles() {
772
+ return /** @type {HTMLElement[]} */ ([...this.refs.list.querySelectorAll(
773
+ ':scope > li > .zx-module-bar__item[data-app-nav-value-id]')]);
774
+ }
775
+
776
+ /** @returns {HTMLElement[]} The tiles and button the arrow keys move between, in bar order. */
777
+ _rovingControls() {
778
+ return /** @type {HTMLElement[]} */ ([...this.refs.list.querySelectorAll(':scope > li:not([hidden]) > .zx-module-bar__item')]);
779
+ }
780
+
781
+ /** @returns {HTMLElement|null} */
782
+ _rovingStop() {
783
+ const controls = this._rovingControls();
784
+ if (this._rovingId !== null) {
785
+ const focused = controls.find((control) => rovingKey(control) === this._rovingId);
786
+ if (focused) return focused;
787
+ }
788
+ return controls.find((control) => control.hasAttribute('aria-current'))
789
+ ?? controls.find((control) => control.getAttribute('aria-expanded') === 'true'
790
+ && control.hasAttribute('data-app-nav-value-id'))
791
+ ?? controls[0] ?? null;
792
+ }
793
+
794
+ /** @param {HTMLElement} control @returns {void} */
795
+ _setRovingStop(control) {
796
+ this._rovingId = rovingKey(control);
797
+ this._syncRoving();
798
+ }
799
+
800
+ /** @returns {void} */
801
+ _syncRoving() {
802
+ const stop = this._rovingStop();
803
+ for (const control of this.refs.list.querySelectorAll(':scope > li > .zx-module-bar__item')) {
804
+ /** @type {HTMLElement} */ (control).tabIndex = control === stop ? 0 : -1;
805
+ }
806
+ }
807
+
808
+ /** @returns {void} */
809
+ _syncExpanded() {
810
+ if (this._menus !== 'none') return;
811
+ const expanded = this._expandedId == null ? null : String(this._expandedId);
812
+ for (const control of this._tiles()) {
813
+ const id = control.getAttribute('data-app-nav-value-id');
814
+ const item = findItem(this._items, id);
815
+ // A module the adjacent panel can show: one with a menu, or with panel content of its own.
816
+ const panelled = Boolean(item?.children?.length || item?.header || item?.content) || id === expanded;
817
+ if (!panelled) continue;
818
+ control.setAttribute('aria-expanded', String(id === expanded));
819
+ if (this.options.controls) control.setAttribute('aria-controls', String(this.options.controls));
820
+ }
821
+ }
822
+
823
+ /** @returns {HTMLElement[]} */
824
+ _catalogueEntries() {
825
+ const panel = this._catalogue?.getPanel();
826
+ return panel ? /** @type {HTMLElement[]} */ ([...panel.querySelectorAll('.zx-module-bar__entry')]) : [];
827
+ }
828
+
829
+ /** @param {string} id @returns {HTMLElement|null} */
830
+ _catalogueEntry(id) {
831
+ return this._catalogueEntries().find((entry) => entry.dataset.moduleId === id) ?? null;
832
+ }
833
+
834
+ /** @returns {string} */
835
+ _labelText() {
836
+ const label = this.options.label;
837
+ return label == null || label === '' ? this._message('moduleBar.label', 'Modules') : String(label);
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Splits modules into those the bar shows and those only "All modules" offers.
843
+ *
844
+ * Bar order is pinned modules first — by their pinned number, then as listed — and the rest as
845
+ * listed. When any module is pinned, unpinned modules go to the catalogue. When the bar cannot
846
+ * hold every candidate, one slot goes to the "All modules" button. Modules named in `keep` (the
847
+ * current module, the module an adjacent panel shows) stay on the bar whatever their pin or
848
+ * position, displacing the last module that is not kept.
849
+ * @param {ModuleItem[]} items Modules.
850
+ * @param {number} [capacity=Infinity] Slots the bar has room for, the button included.
851
+ * @param {Array<string|number|null|undefined>|string|number|null} [keep=[]] Module ids that must stay visible.
852
+ * @param {boolean} [reserve=false] Keep a slot for the button even when every module fits.
853
+ * @returns {{visible: ModuleItem[], overflow: ModuleItem[], more: boolean}}
854
+ */
855
+ export function partitionModules(items, capacity = Infinity, keep = [], reserve = false) {
856
+ const list = Array.isArray(items) ? items.filter((item) => item && item.id != null) : [];
857
+ const order = barOrder(list);
858
+ const pinnedMode = list.some(isPinned);
859
+ const candidates = pinnedMode ? order.filter(isPinned) : order;
860
+ const limit = Number.isFinite(capacity) ? Math.max(1, Math.floor(capacity)) : Infinity;
861
+ const needsMore = reserve || candidates.length < list.length || candidates.length > limit;
862
+ const slots = needsMore ? Math.max(0, limit - 1) : limit;
863
+ const visible = candidates.slice(0, slots);
864
+ const kept = new Set([keep].flat().filter((id) => id != null).map(String));
865
+ for (const id of kept) {
866
+ const item = list.find((candidate) => String(candidate.id) === id);
867
+ if (!item || visible.includes(item)) continue;
868
+ if (visible.length >= slots) {
869
+ const displaced = visible.findLastIndex((candidate) => !kept.has(String(candidate.id)));
870
+ if (displaced >= 0) visible.splice(displaced, 1);
871
+ }
872
+ visible.push(item);
873
+ }
874
+ visible.sort((first, second) => order.indexOf(first) - order.indexOf(second));
875
+ const overflow = list.filter((item) => !visible.includes(item));
876
+ return { visible, overflow, more: overflow.length > 0 };
877
+ }
878
+
879
+ /**
880
+ * Resolves a module's identity colour: a valid CSS `color` wins, then a known `tone`.
881
+ * @param {{tone?: unknown, color?: unknown}} item Module.
882
+ * @returns {{tone: ModuleTone|null, color: string|null}}
883
+ */
884
+ export function resolveModuleColor(item) {
885
+ const color = isCssColor(item?.color) ? String(item.color).trim() : null;
886
+ if (color) return { tone: null, color };
887
+ const tone = APP_ICON_TONES.includes(/** @type {any} */ (item?.tone)) ? /** @type {ModuleTone} */ (item.tone) : null;
888
+ return { tone, color: null };
889
+ }
890
+
891
+ /**
892
+ * Modules are one level: a section contributes its modules, and remembers itself as their heading.
893
+ * @param {AppNavItem[]} items Normalized items.
894
+ * @param {Map<string, string>} [sections] Filled with each sectioned module's heading.
895
+ * @param {string|null} [heading=null] The section being lifted.
896
+ * @returns {AppNavItem[]}
897
+ */
898
+ export function liftSections(items, sections = new Map(), heading = null) {
899
+ return items.flatMap((item) => {
900
+ if (item.section) return liftSections(item.children ?? [], sections, item.label);
901
+ if (heading !== null) sections.set(String(item.id), heading);
902
+ return [item];
903
+ });
904
+ }
905
+
906
+ /**
907
+ * Shows a section heading only while one of its modules is on the bar, and marks the heading
908
+ * that opens the bar, which an icon strip leaves out: a rule above the first module separates
909
+ * nothing.
910
+ * @param {HTMLElement} list Bar list.
911
+ * @returns {void}
912
+ */
913
+ function syncHeadings(list) {
914
+ let heading = null;
915
+ let any = false;
916
+ let leading = true;
917
+ const close = () => {
918
+ if (heading) heading.hidden = !any;
919
+ };
920
+ for (const row of /** @type {HTMLElement[]} */ ([...list.children])) {
921
+ if (row.classList.contains('zx-module-bar__heading')) {
922
+ close();
923
+ heading = row;
924
+ any = false;
925
+ if (leading) row.dataset.leading = '';
926
+ else delete row.dataset.leading;
927
+ continue;
928
+ }
929
+ if (row.hasAttribute('data-module-id') && !row.hidden) {
930
+ any = true;
931
+ leading = false;
932
+ }
933
+ }
934
+ close();
935
+ }
936
+
937
+ /**
938
+ * The labels a bar of this flow can print: vertical bars print them under or beside the tiles,
939
+ * horizontal bars never.
940
+ * @param {unknown} labels Requested mode.
941
+ * @param {string|undefined} orientation Bar flow.
942
+ * @returns {'hidden'|'below'|'beside'}
943
+ */
944
+ function labelsMode(labels, orientation) {
945
+ if (orientation !== 'vertical') return 'hidden';
946
+ return labels === 'below' || labels === 'beside' ? labels : 'hidden';
947
+ }
948
+
949
+ /** @param {ModuleItem} item @returns {boolean} */
950
+ function isPinned(item) {
951
+ return item.pinned === true || (typeof item.pinned === 'number' && Number.isFinite(item.pinned));
952
+ }
953
+
954
+ /** @param {ModuleItem[]} items @returns {ModuleItem[]} Pinned first, by pinned number, then as listed. */
955
+ function barOrder(items) {
956
+ const rank = (item) => (typeof item.pinned === 'number' && Number.isFinite(item.pinned) ? item.pinned
957
+ : item.pinned === true ? Number.MAX_SAFE_INTEGER - 1 : Number.MAX_SAFE_INTEGER);
958
+ return items.map((item, index) => ({ item, index }))
959
+ .sort((first, second) => rank(first.item) - rank(second.item) || first.index - second.index)
960
+ .map(({ item }) => item);
961
+ }
962
+
963
+ /**
964
+ * How many rows fit the list along its main axis, measured with every row shown.
965
+ * @param {HTMLElement} list Bar list.
966
+ * @param {boolean} horizontal Flow.
967
+ * @param {number} count Rows, the button included.
968
+ * @returns {number}
969
+ */
970
+ function measureCapacity(list, horizontal, count) {
971
+ const overflowing = horizontal ? list.scrollWidth > list.clientWidth + 1 : list.scrollHeight > list.clientHeight + 1;
972
+ if (!overflowing) return Infinity;
973
+ // Names may wrap a row to two lines: the tallest row decides, so no row is ever cut in half.
974
+ const rows = /** @type {HTMLElement[]} */ ([...list.querySelectorAll(':scope > li:not([hidden]):not(.zx-module-bar__heading)')]);
975
+ if (!rows.length) return Infinity;
976
+ const headings = /** @type {HTMLElement[]} */ ([...list.querySelectorAll(':scope > li.zx-module-bar__heading:not([hidden])')]);
977
+ const style = getComputedStyle(list);
978
+ const gap = Number.parseFloat(horizontal ? style.columnGap : style.rowGap) || 0;
979
+ const padding = horizontal
980
+ ? (Number.parseFloat(style.paddingInlineStart) || 0) + (Number.parseFloat(style.paddingInlineEnd) || 0)
981
+ : (Number.parseFloat(style.paddingBlockStart) || 0) + (Number.parseFloat(style.paddingBlockEnd) || 0);
982
+ const size = Math.max(...rows.map((row) => (horizontal ? row.offsetWidth : row.offsetHeight)));
983
+ const headingSpace = headings.reduce((sum, heading) => sum + gap + (horizontal ? heading.offsetWidth : heading.offsetHeight)
984
+ + (Number.parseFloat(horizontal ? getComputedStyle(heading).marginInlineStart : getComputedStyle(heading).marginBlockStart) || 0)
985
+ + (Number.parseFloat(horizontal ? getComputedStyle(heading).marginInlineEnd : getComputedStyle(heading).marginBlockEnd) || 0), 0);
986
+ const available = (horizontal ? list.clientWidth : list.clientHeight) - padding - headingSpace;
987
+ if (size <= 0 || available <= 0) return count;
988
+ return Math.max(1, Math.floor((available + gap) / (size + gap)));
989
+ }
990
+
991
+ /** @param {HTMLElement[]} entries @param {HTMLElement} current @param {1|-1} direction @returns {HTMLElement|null} */
992
+ function nearestInRow(entries, current, direction) {
993
+ const origin = current.getBoundingClientRect();
994
+ const centre = origin.left + origin.width / 2;
995
+ let best = null;
996
+ let bestRow = Infinity;
997
+ let bestDistance = Infinity;
998
+ for (const entry of entries) {
999
+ if (entry === current) continue;
1000
+ const rect = entry.getBoundingClientRect();
1001
+ const rowGap = direction > 0 ? rect.top - origin.bottom + 1 : origin.top - rect.bottom + 1;
1002
+ if (rowGap < 0) continue;
1003
+ const distance = Math.abs(rect.left + rect.width / 2 - centre);
1004
+ if (rowGap < bestRow - 1 || (Math.abs(rowGap - bestRow) <= 1 && distance < bestDistance)) {
1005
+ best = entry;
1006
+ bestRow = rowGap;
1007
+ bestDistance = distance;
1008
+ }
1009
+ }
1010
+ return best;
1011
+ }
1012
+
1013
+ /** @param {HTMLElement} control @returns {string} */
1014
+ function rovingKey(control) {
1015
+ return control.getAttribute('data-app-nav-value-id') ?? '\u0000more';
1016
+ }
1017
+
1018
+ /** @param {AppNavItem} item @param {string|null} activeId @returns {'page'|'true'|null} */
1019
+ function currentState(item, activeId) {
1020
+ if (activeId === null) return null;
1021
+ if (String(item.id) === activeId) return 'page';
1022
+ return containsItemId(item.children ?? [], activeId) ? 'true' : null;
1023
+ }
1024
+
1025
+ /**
1026
+ * Updates the trailing badge of a row-shaped control (a flyout item, a catalogue entry).
1027
+ * @param {Element} control Row control.
1028
+ * @param {AppNavItem} item Its item.
1029
+ * @param {string} block The control's class, which names its parts.
1030
+ * @returns {void}
1031
+ */
1032
+ function applyRowBadge(control, item, block) {
1033
+ const existing = control.querySelector(`:scope > .${block}-badge`);
1034
+ const count = item.badge == null || item.badge === '' ? null : String(item.badge);
1035
+ if (count === null) {
1036
+ existing?.remove();
1037
+ return;
1038
+ }
1039
+ if (existing) existing.textContent = count;
1040
+ else control.querySelector(`:scope > .${block}-label`)?.after(h('span', { class: `${block}-badge` }, count));
1041
+ }
1042
+
1043
+ /**
1044
+ * The top-level module that is, or contains, an id.
1045
+ * @param {AppNavItem[]} items Modules.
1046
+ * @param {string|number|null|undefined} id Destination id.
1047
+ * @returns {AppNavItem|null}
1048
+ */
1049
+ export function moduleOf(items, id) {
1050
+ if (id == null || !Array.isArray(items)) return null;
1051
+ const key = String(id);
1052
+ return items.find((item) => String(item.id) === key || containsItemId(item.children ?? [], key)) ?? null;
1053
+ }
1054
+
1055
+ /** @param {AppNavItem[]} items @param {string|number|null|undefined} id @returns {AppNavItem|null} */
1056
+ function findItem(items, id) {
1057
+ if (id == null) return null;
1058
+ for (const item of items ?? []) {
1059
+ if (String(item.id) === String(id)) return item;
1060
+ const child = findItem(item.children ?? [], id);
1061
+ if (child) return child;
1062
+ }
1063
+ return null;
1064
+ }
1065
+
1066
+ /** @param {AppNavItem[]} items @param {string} id @returns {boolean} */
1067
+ function containsItemId(items, id) {
1068
+ return items.some((item) => String(item.id) === id || containsItemId(item.children ?? [], id));
1069
+ }
1070
+
1071
+ /** Fired when a module without a menu, a menu item, or (with `menus: 'none'`) any module is activated. @event ModuleBar#select @type {CustomEvent<AppNavSelectDetail>} */
1072
+ /** Fired when a module's flyout opens or closes. @event ModuleBar#flyoutchange @type {CustomEvent<{item: AppNavItem, id: string|number, open: boolean}>} */
1073
+ /** Fired when the modules on the bar change because of space, pins, or the current module. @event ModuleBar#overflowchange @type {CustomEvent<{visible: Array<string|number>, overflow: Array<string|number>}>} */
1074
+ /** Fired when the "All modules" catalogue opens or closes. @event ModuleBar#allmodules @type {CustomEvent<{open: boolean}>} */