@zeyos/zx 3.0.0 → 4.0.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 (88) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/DESIGN-SYSTEM.md +28 -68
  3. package/README.md +16 -37
  4. package/dist/types/components/app-sidebar/app-sidebar.d.ts +106 -32
  5. package/dist/types/components/card/card.d.ts +128 -0
  6. package/dist/types/components/chart/chart.d.ts +1 -0
  7. package/dist/types/components/launcher/launcher.d.ts +65 -6
  8. package/dist/types/components/select/select.d.ts +51 -0
  9. package/dist/types/components/table/table.d.ts +4 -3
  10. package/dist/types/core/dom.d.ts +8 -0
  11. package/dist/types/index.d.ts +2 -1
  12. package/dist/types/{components/app-rail → internal}/app-rail.d.ts +34 -19
  13. package/dist/types/zeyos/icons.d.ts +5 -0
  14. package/dist/types/zeyos/index.d.ts +2 -1
  15. package/dist/types/zeyos/launcher.d.ts +147 -0
  16. package/dist/types/zeyos/select.d.ts +39 -0
  17. package/dist/zx-zeyos.esm.js +1324 -46
  18. package/dist/zx-zeyos.esm.min.js +1 -1
  19. package/dist/zx.css +400 -12
  20. package/dist/zx.esm.js +1059 -189
  21. package/dist/zx.esm.min.js +3 -3
  22. package/dist/zx.global.js +1059 -189
  23. package/dist/zx.global.min.js +3 -3
  24. package/dist/zx.min.css +1 -1
  25. package/docs/api.json +258 -145
  26. package/docs/llms.md +93 -112
  27. package/docs/llms.txt +16 -15
  28. package/package.json +8 -17
  29. package/src/components/app-sidebar/app-sidebar.css +12 -0
  30. package/src/components/app-sidebar/app-sidebar.js +310 -110
  31. package/src/components/card/card.css +149 -0
  32. package/src/components/card/card.js +285 -0
  33. package/src/components/chart/chart.js +8 -3
  34. package/src/components/grid/grid.js +10 -3
  35. package/src/components/launcher/launcher.css +113 -3
  36. package/src/components/launcher/launcher.js +234 -26
  37. package/src/components/select/field-adapter.js +9 -4
  38. package/src/components/select/select.css +144 -10
  39. package/src/components/select/select.js +259 -27
  40. package/src/components/table/table.js +8 -7
  41. package/src/core/dom.js +20 -0
  42. package/src/index.js +2 -1
  43. package/src/{components/app-rail → internal}/app-rail.js +80 -30
  44. package/src/zeyos/icons.js +8 -3
  45. package/src/zeyos/index.js +7 -1
  46. package/src/zeyos/launcher.js +338 -0
  47. package/src/zeyos/modules.js +1 -0
  48. package/src/zeyos/select.js +115 -1
  49. package/styles/tokens/global.css +1 -1
  50. package/styles/tokens/modules.css +1 -0
  51. package/styles/zx.css +1 -0
  52. package/MIGRATION.md +0 -749
  53. package/dist/types/compat/base.d.ts +0 -156
  54. package/dist/types/compat/globals.d.ts +0 -20
  55. package/dist/types/compat/index.d.ts +0 -128
  56. package/dist/types/compat/map/basic.d.ts +0 -175
  57. package/dist/types/compat/map/data.d.ts +0 -103
  58. package/dist/types/compat/map/date-time.d.ts +0 -80
  59. package/dist/types/compat/map/factory.d.ts +0 -9
  60. package/dist/types/compat/map/form.d.ts +0 -98
  61. package/dist/types/compat/map/helpers.d.ts +0 -12
  62. package/dist/types/compat/map/http.d.ts +0 -36
  63. package/dist/types/compat/map/layout.d.ts +0 -106
  64. package/dist/types/compat/map/options.d.ts +0 -80
  65. package/dist/types/compat/map/select.d.ts +0 -95
  66. package/dist/types/compat/map/stubs.d.ts +0 -32
  67. package/dist/types/compat/map/utilities.d.ts +0 -37
  68. package/dist/types/compat-entry.d.ts +0 -5
  69. package/dist/zx-compat.esm.js +0 -17848
  70. package/dist/zx-compat.esm.min.js +0 -1
  71. package/dist/zx-compat.global.js +0 -29008
  72. package/dist/zx-compat.global.min.js +0 -3
  73. package/src/compat/base.js +0 -291
  74. package/src/compat/globals.js +0 -167
  75. package/src/compat/index.js +0 -84
  76. package/src/compat/map/basic.js +0 -464
  77. package/src/compat/map/data.js +0 -254
  78. package/src/compat/map/date-time.js +0 -177
  79. package/src/compat/map/factory.js +0 -45
  80. package/src/compat/map/form.js +0 -330
  81. package/src/compat/map/helpers.js +0 -46
  82. package/src/compat/map/http.js +0 -117
  83. package/src/compat/map/layout.js +0 -256
  84. package/src/compat/map/options.js +0 -267
  85. package/src/compat/map/select.js +0 -198
  86. package/src/compat/map/stubs.js +0 -26
  87. package/src/compat/map/utilities.js +0 -113
  88. package/src/compat-entry.js +0 -11
@@ -2,32 +2,42 @@ import { Component } from '../../core/component.js';
2
2
  import { h } from '../../core/dom.js';
3
3
  import { icon } from '../../core/icons.js';
4
4
  import { uid } from '../../core/util.js';
5
- import { AppRail, normalizeAppItems } from '../app-rail/app-rail.js';
5
+ import {
6
+ AppRailPresenter as MinimizedPresenter, appNavIcon, normalizeAppItems
7
+ } from '../../internal/app-rail.js';
6
8
 
7
- /** @typedef {import('../app-rail/app-rail.js').AppNavItem} AppNavItem */
8
- /** @typedef {import('../app-rail/app-rail.js').AppNavSelectDetail} AppNavSelectDetail */
9
+ /** @typedef {import('../../internal/app-rail.js').AppNavItem} AppNavItem */
10
+ /** @typedef {import('../../internal/app-rail.js').AppNavSelectDetail} AppNavSelectDetail */
9
11
  /**
10
12
  * @typedef {Object} AppSidebarOptions
11
13
  * @property {AppNavItem[]} [items=[]] Application navigation tree.
12
14
  * @property {string|number|null} [active=null] Active item id.
13
15
  * @property {Array<string|number>} [expanded=[]] Initially expanded branch ids.
14
- * @property {boolean} [collapsed=false] Whether the minimized AppRail is shown.
15
- * @property {'left'|'right'} [side='left'] Workspace side.
16
+ * @property {boolean} [collapsed=false] Whether the minimized presentation is shown.
17
+ * @property {boolean} [collapsible=true] Whether vertical navigation exposes a mode toggle.
18
+ * @property {'vertical'|'horizontal'} [orientation='vertical'] Navigation flow; horizontal is always minimized.
19
+ * @property {'left'|'right'|'top'|'bottom'} [side='left'] Workspace edge occupied by navigation.
16
20
  * @property {string} [label='Applications'] Navigation accessible label.
17
21
  * @property {Node|string|number|{toElement: () => Node|null}|null} [header=null] Expanded header content.
18
22
  * @property {Node|string|number|{toElement: () => Node|null}|null} [footer=null] Expanded sticky footer content.
19
- * @property {Node|string|number|{toElement: () => Node|null}|null} [railHeader=null] Minimized rail header content.
20
- * @property {Node|string|number|{toElement: () => Node|null}|null} [railFooter=null] Minimized rail footer content.
23
+ * @property {Node|string|number|{toElement: () => Node|null}|null} [railHeader=null] Minimized leading content.
24
+ * @property {Node|string|number|{toElement: () => Node|null}|null} [railFooter=null] Minimized trailing content.
25
+ * @property {number} [openDelay=80] Minimized flyout hover-open delay.
26
+ * @property {number} [closeDelay=160] Minimized flyout crossing grace.
27
+ * @property {(item: AppNavItem, context: Record<string, any>) => Node|null} [renderIcon] Optional host icon renderer.
21
28
  * @property {(event: CustomEvent<AppNavSelectDetail>) => void} [onselect] Preventable action listener.
22
- * @property {(event: CustomEvent<{item: AppNavItem|null, id: string|number|null, expanded: boolean|null, ids: Array<string|number>}>) => void} [onbranchchange] Branch-state listener; bulk replacement reports null state.
29
+ * @property {(event: CustomEvent<{item: AppNavItem|null, id: string|number|null, expanded: boolean|null, ids: Array<string|number>}>) => void} [onbranchchange] Branch-state listener.
23
30
  * @property {(event: CustomEvent<{collapsed: boolean}>) => void} [oncollapsechange] Presentation-state listener.
31
+ * @property {(event: CustomEvent<{item: AppNavItem, id: string|number, open: boolean}>) => void} [onflyoutchange] Minimized flyout-state listener.
24
32
  */
25
33
 
26
34
  /**
27
- * Expanded vertical application navigation; the only Xenon shell mode with inline descendants.
35
+ * One application navigation component. Vertical expanded descendants render inline; minimized
36
+ * vertical and horizontal descendants use pointer-, focus-, and keyboard-accessible flyouts.
28
37
  * @fires AppSidebar#select
29
38
  * @fires AppSidebar#branchchange
30
39
  * @fires AppSidebar#collapsechange
40
+ * @fires AppSidebar#flyoutchange
31
41
  * @extends {Component<AppSidebarOptions>}
32
42
  */
33
43
  export class AppSidebar extends Component {
@@ -39,48 +49,29 @@ export class AppSidebar extends Component {
39
49
  active: null,
40
50
  expanded: [],
41
51
  collapsed: false,
52
+ collapsible: true,
53
+ orientation: 'vertical',
42
54
  side: 'left',
43
55
  label: 'Applications',
44
56
  header: null,
45
57
  footer: null,
46
58
  railHeader: null,
47
- railFooter: null
59
+ railFooter: null,
60
+ openDelay: 80,
61
+ closeDelay: 160,
62
+ renderIcon: null
48
63
  };
49
64
 
50
65
  /**
51
- * Creates or enhances an application-sidebar host.
52
- * @param {Element|string|null} target Sidebar target.
53
- * @param {AppSidebarOptions} [options={}] Sidebar options.
66
+ * Creates or enhances an application-navigation host.
67
+ * @param {Element|string|null} [target=null] Navigation target.
68
+ * @param {AppSidebarOptions} [options={}] Navigation options.
54
69
  */
55
70
  constructor(target = null, options = {}) {
56
71
  super(target, options);
57
- this.listen(this.refs.rail, 'zx-select', (event) => event.stopPropagation());
58
- const expandButton = h('button', {
59
- class: 'zx-app-sidebar__rail-toggle zx-icon-btn',
60
- type: 'button',
61
- ariaLabel: 'Expand application sidebar'
62
- }, icon(this.el.dataset.side === 'right' ? 'chevron-left' : 'chevron-right'));
63
- this._railExpandButton = expandButton;
64
- const railHeader = h('div', { class: 'zx-app-sidebar__rail-header' }, expandButton);
65
- appendSlot(railHeader, this.options.railHeader);
66
- this._rail = new AppRail(this.refs.rail, {
67
- items: this._items,
68
- active: this._active,
69
- orientation: 'vertical',
70
- side: this.el.dataset.side,
71
- label: this.options.label,
72
- header: railHeader,
73
- footer: this.options.railFooter,
74
- onselect: (event) => {
75
- const selected = this.emit('select', event.detail);
76
- if (selected.defaultPrevented) event.preventDefault();
77
- }
78
- });
79
- this.listen(expandButton, 'click', () => {
80
- this.expand();
81
- });
82
- this._renderTree();
83
- this.setCollapsed(Boolean(this.options.collapsed), { silent: true });
72
+ this._rail = null;
73
+ this._railExpandButton = null;
74
+ this._renderMode();
84
75
  }
85
76
 
86
77
  /** @returns {HTMLElement} */
@@ -94,49 +85,38 @@ export class AppSidebar extends Component {
94
85
  };
95
86
  this._items = normalizeAppItems(this.options.items);
96
87
  this._active = this.options.active;
97
- this._expanded = new Set(Array.isArray(this.options.expanded) ? this.options.expanded.map(String) : []);
88
+ this._expanded = new Set(Array.isArray(this.options.expanded)
89
+ ? this.options.expanded.map(String) : []);
98
90
  this._collapsed = Boolean(this.options.collapsed);
99
- root.dataset.side = this.options.side === 'right' ? 'right' : 'left';
100
-
101
- const headerContent = h('div', { class: 'zx-app-sidebar__header-content' });
102
- appendSlot(headerContent, this.options.header);
103
- const collapseButton = h('button', {
104
- ref: 'collapse',
105
- class: 'zx-app-sidebar__collapse zx-icon-btn',
106
- type: 'button',
107
- ariaLabel: 'Minimize application sidebar'
108
- }, icon(root.dataset.side === 'right' ? 'chevron-right' : 'chevron-left'));
109
- const footer = h('footer', { class: 'zx-app-sidebar__footer' });
110
- appendSlot(footer, this.options.footer);
111
- footer.hidden = !footer.hasChildNodes();
112
- const expanded = h('div', { ref: 'expanded', class: 'zx-app-sidebar__expanded' },
113
- h('header', { class: 'zx-app-sidebar__header' }, headerContent, collapseButton),
114
- h('nav', { class: 'zx-app-sidebar__nav', ariaLabel: this.options.label },
115
- h('ul', { ref: 'list', class: 'zx-app-sidebar__list' })),
116
- footer);
117
- const rail = h('div', { ref: 'rail', class: 'zx-app-sidebar__rail' });
118
- root.replaceChildren(expanded, rail);
119
- this.listen(collapseButton, 'click', () => {
120
- this.collapse();
121
- });
122
- this.listen(this.refs.list, 'click', (event) => this._onTreeClick(event));
91
+ this._orientation = this.options.orientation === 'horizontal' ? 'horizontal' : 'vertical';
92
+ this._side = normalizeSide(this._orientation, this.options.side);
93
+ this._modeAbort = null;
94
+ root.replaceChildren();
123
95
  return root;
124
96
  }
125
97
 
126
- /** Replaces the navigation tree in both expanded and rail presentations. @param {AppNavItem[]} items @returns {this} */
98
+ /** Replaces the navigation tree in the active presentation. @param {AppNavItem[]} items @returns {this} */
127
99
  setItems(items) {
128
100
  this._items = normalizeAppItems(items);
129
101
  this._reconcileExpanded();
130
- this._renderTree();
131
- this._rail.setItems(this._items);
102
+ if (this._rail) this._rail.setItems(this._items);
103
+ else this._renderTree();
132
104
  return this;
133
105
  }
134
106
 
135
- /** Sets the active id in both presentations without routing. @param {string|number|null} id @returns {this} */
107
+ /** Returns a shallow copy of normalized items. @returns {AppNavItem[]} */
108
+ getItems() {
109
+ return this._items.map((item) => ({
110
+ ...item,
111
+ children: item.children?.map((child) => ({ ...child }))
112
+ }));
113
+ }
114
+
115
+ /** Sets the active id without routing. @param {string|number|null} id @returns {this} */
136
116
  setActive(id) {
137
117
  this._active = id;
138
- this._syncActive();
139
- this._rail.setActive(id);
118
+ if (this._rail) this._rail.setActive(id);
119
+ else this._syncActive();
140
120
  return this;
141
121
  }
142
122
 
@@ -144,7 +124,7 @@ export class AppSidebar extends Component {
144
124
  setExpanded(ids, { silent = false } = {}) {
145
125
  this._expanded = new Set(Array.isArray(ids) ? ids.map(String) : []);
146
126
  this._reconcileExpanded();
147
- this._renderTree();
127
+ if (!this._rail) this._renderTree();
148
128
  if (!silent) this.emit('branchchange', {
149
129
  item: null,
150
130
  id: null,
@@ -171,53 +151,122 @@ export class AppSidebar extends Component {
171
151
  const expanded = !this._expanded.has(key);
172
152
  if (expanded) this._expanded.add(key);
173
153
  else this._expanded.delete(key);
174
- this._renderTree();
175
- if (!silent) this.emit('branchchange', { item, id: item.id, expanded, ids: this.getExpanded() });
154
+ if (!this._rail) this._renderTree();
155
+ if (!silent) this.emit('branchchange', {
156
+ item,
157
+ id: item.id,
158
+ expanded,
159
+ ids: this.getExpanded()
160
+ });
176
161
  return this;
177
162
  }
178
163
 
179
- /** Shows the expanded sidebar presentation. @param {{silent?: boolean}} [options={}] @returns {this} */
164
+ /** Shows the expanded vertical presentation. @param {{silent?: boolean}} [options={}] @returns {this} */
180
165
  expand(options = {}) {
181
166
  return this.setCollapsed(false, options);
182
167
  }
183
168
 
184
- /** Shows the minimized AppRail presentation. @param {{silent?: boolean}} [options={}] @returns {this} */
169
+ /** Shows the minimized presentation. @param {{silent?: boolean}} [options={}] @returns {this} */
185
170
  collapse(options = {}) {
186
171
  return this.setCollapsed(true, options);
187
172
  }
188
173
 
189
- /** Sets expanded/minimized presentation while preserving branch state. @param {boolean} collapsed @param {{silent?: boolean}} [options={}] @returns {this} */
174
+ /** Sets the saved vertical expanded/minimized state. @param {boolean} collapsed @param {{silent?: boolean}} [options={}] @returns {this} */
190
175
  setCollapsed(collapsed, { silent = false } = {}) {
191
- const next = Boolean(collapsed);
192
- const changed = next !== this._collapsed;
176
+ const previous = this.isCollapsed();
193
177
  const active = document.activeElement;
194
- const focusRailToggle = next && Boolean(active && this.refs.expanded.contains(active));
195
- const focusSidebarToggle = !next && this._rail?._containsNode(active);
196
- if (!next) this._rail?.closeAllFlyouts();
197
- this._collapsed = next;
198
- this.el.dataset.collapsed = String(next);
199
- this.refs.expanded.hidden = next;
200
- this.refs.rail.hidden = !next;
201
- if (focusRailToggle) this._railExpandButton?.focus();
202
- else if (focusSidebarToggle) this.refs.collapse.focus();
203
- if (changed && !silent) this.emit('collapsechange', { collapsed: next });
178
+ const outgoingOwnedFocus = Boolean(active
179
+ && (this.el.contains(active) || this._rail?._containsNode(active)));
180
+ this._collapsed = Boolean(collapsed);
181
+ const next = this.isCollapsed();
182
+ if (previous !== next) {
183
+ this._renderMode();
184
+ if (outgoingOwnedFocus) {
185
+ if (next) this._railExpandButton?.focus();
186
+ else this.refs.collapse?.focus();
187
+ }
188
+ if (!silent) this.emit('collapsechange', { collapsed: next });
189
+ }
204
190
  return this;
205
191
  }
206
192
 
207
- /** Reports whether the rail presentation is active. @returns {boolean} */
193
+ /** Reports the effective presentation state; horizontal navigation is always minimized. @returns {boolean} */
208
194
  isCollapsed() {
209
- return this._collapsed;
195
+ return this._orientation === 'horizontal' || this._collapsed;
196
+ }
197
+
198
+ /**
199
+ * Changes navigation flow/edge without replacing state.
200
+ * @param {{orientation?: 'vertical'|'horizontal', side?: 'left'|'right'|'top'|'bottom'}} layout
201
+ * @param {{silent?: boolean}} [options]
202
+ * @returns {this}
203
+ */
204
+ setLayout(layout = {}, { silent = false } = {}) {
205
+ const orientation = layout.orientation === 'horizontal' ? 'horizontal'
206
+ : layout.orientation === 'vertical' ? 'vertical' : this._orientation;
207
+ const side = normalizeSide(orientation, layout.side ?? this._side);
208
+ if (orientation === this._orientation && side === this._side) return this;
209
+ const previous = this.isCollapsed();
210
+ const active = document.activeElement;
211
+ const outgoingOwnedFocus = Boolean(active
212
+ && (this.el.contains(active) || this._rail?._containsNode(active)));
213
+ const focusedId = outgoingOwnedFocus && active instanceof Element
214
+ ? active.closest('[data-app-nav-id]')?.getAttribute('data-app-nav-id') ?? null : null;
215
+ this._orientation = orientation;
216
+ this._side = side;
217
+ this._renderMode();
218
+ const next = this.isCollapsed();
219
+ if (outgoingOwnedFocus) {
220
+ const replacement = focusedId === null ? null
221
+ : [...this.el.querySelectorAll('[data-app-nav-id]')]
222
+ .find((control) => control.getAttribute('data-app-nav-id') === focusedId) ?? null;
223
+ if (replacement instanceof HTMLElement) replacement.focus();
224
+ else if (next) this.focus();
225
+ else if (this.refs.collapse instanceof HTMLElement && !this.refs.collapse.hidden) {
226
+ this.refs.collapse.focus();
227
+ } else visibleTreeControls(this.refs.list)[0]?.focus();
228
+ }
229
+ if (previous !== next && !silent) this.emit('collapsechange', { collapsed: next });
230
+ return this;
231
+ }
232
+
233
+ /** Opens a minimized descendant flyout. @param {string|number} id @param {{focus?: boolean}} [options={}] @returns {this} */
234
+ openFlyout(id, options = {}) {
235
+ this._rail?.openFlyout(id, options);
236
+ return this;
237
+ }
238
+
239
+ /** Closes a minimized descendant flyout. @param {string|number} id @returns {this} */
240
+ closeFlyout(id) {
241
+ this._rail?.closeFlyout(id);
242
+ return this;
243
+ }
244
+
245
+ /** Closes every minimized flyout. @returns {this} */
246
+ closeAllFlyouts() {
247
+ this._rail?.closeAllFlyouts();
248
+ return this;
210
249
  }
211
250
 
212
- /** Returns the composed minimized rail. @returns {AppRail} */
213
- getRail() {
214
- return this._rail;
251
+ /** Reports whether a minimized flyout is open. @param {string|number} id @returns {boolean} */
252
+ isFlyoutOpen(id) {
253
+ return this._rail?.isFlyoutOpen(id) ?? false;
215
254
  }
216
255
 
217
- /** Destroys the rail and restores/removes the sidebar host. @returns {void} */
256
+ /** Focuses the first navigation action. @returns {this} */
257
+ focus() {
258
+ if (this._rail) this._rail.focus();
259
+ else visibleTreeControls(this.refs.list)[0]?.focus();
260
+ return this;
261
+ }
262
+
263
+ /** Destroys flyouts and restores/removes the host. @returns {void} */
218
264
  destroy() {
219
265
  const original = this._original;
220
- this._rail.destroy();
266
+ this._modeAbort?.abort();
267
+ this._modeAbort = null;
268
+ this._rail?.destroy();
269
+ this._rail = null;
221
270
  super.destroy();
222
271
  if (!this._createdRoot && original) {
223
272
  for (const attribute of Array.from(this.el.attributes)) this.el.removeAttribute(attribute.name);
@@ -226,10 +275,100 @@ export class AppSidebar extends Component {
226
275
  }
227
276
  }
228
277
 
278
+ /** Mounts exactly one expanded tree or minimized flyout presentation. @returns {void} */
279
+ _renderMode() {
280
+ this._modeAbort?.abort();
281
+ this._modeAbort = new AbortController();
282
+ this._rail?.destroy();
283
+ this._rail = null;
284
+ this._railExpandButton = null;
285
+ for (const key of ['expanded', 'list', 'collapse', 'rail']) delete this.refs[key];
286
+ this.el.replaceChildren();
287
+ this.el.dataset.orientation = this._orientation;
288
+ this.el.dataset.side = this._side;
289
+ this.el.dataset.collapsed = String(this.isCollapsed());
290
+ if (this.isCollapsed()) this._renderRail();
291
+ else this._renderExpanded();
292
+ }
293
+
294
+ /** @returns {void} */
295
+ _renderExpanded() {
296
+ const headerContent = h('div', { class: 'zx-app-sidebar__header-content' });
297
+ appendSlot(headerContent, this.options.header);
298
+ const collapseButton = h('button', {
299
+ class: 'zx-app-sidebar__collapse zx-icon-btn',
300
+ type: 'button',
301
+ ariaLabel: 'Minimize application sidebar'
302
+ }, icon(this._side === 'right' ? 'chevron-right' : 'chevron-left'));
303
+ collapseButton.hidden = !this.options.collapsible;
304
+ const footer = h('footer', { class: 'zx-app-sidebar__footer' });
305
+ appendSlot(footer, this.options.footer);
306
+ footer.hidden = !footer.hasChildNodes();
307
+ const list = h('ul', { class: 'zx-app-sidebar__list' });
308
+ const expanded = h('div', { class: 'zx-app-sidebar__expanded' },
309
+ h('header', { class: 'zx-app-sidebar__header' }, headerContent, collapseButton),
310
+ h('nav', { class: 'zx-app-sidebar__nav', ariaLabel: this.options.label }, list),
311
+ footer);
312
+ this.refs.expanded = expanded;
313
+ this.refs.list = list;
314
+ this.refs.collapse = collapseButton;
315
+ this.el.append(expanded);
316
+ this._listenMode(collapseButton, 'click', () => this.collapse());
317
+ this._listenMode(list, 'click', (event) => this._onTreeClick(event));
318
+ this._listenMode(list, 'keydown', (event) => this._onTreeKeydown(/** @type {KeyboardEvent} */ (event)));
319
+ this._renderTree();
320
+ }
321
+
322
+ /** @returns {void} */
323
+ _renderRail() {
324
+ const railHost = h('nav', { class: 'zx-app-sidebar__rail' });
325
+ this.el.append(railHost);
326
+ this.refs.rail = railHost;
327
+ const showToggle = this._orientation === 'vertical' && Boolean(this.options.collapsible);
328
+ const railHeader = h('div', { class: 'zx-app-sidebar__rail-header' });
329
+ if (showToggle) {
330
+ const expandButton = h('button', {
331
+ class: 'zx-app-sidebar__rail-toggle zx-icon-btn',
332
+ type: 'button',
333
+ ariaLabel: 'Expand application sidebar'
334
+ }, icon(this._side === 'right' ? 'chevron-left' : 'chevron-right'));
335
+ railHeader.append(expandButton);
336
+ this._railExpandButton = expandButton;
337
+ this._listenMode(expandButton, 'click', () => this.expand());
338
+ }
339
+ appendSlot(railHeader, this.options.railHeader);
340
+ this._rail = new MinimizedPresenter(railHost, {
341
+ items: this._items,
342
+ active: this._active,
343
+ orientation: this._orientation,
344
+ side: this._side,
345
+ label: this.options.label,
346
+ header: railHeader.hasChildNodes() ? railHeader : null,
347
+ footer: this.options.railFooter,
348
+ openDelay: this.options.openDelay,
349
+ closeDelay: this.options.closeDelay,
350
+ renderIcon: this.options.renderIcon,
351
+ onselect: (event) => {
352
+ const selected = this.emit('select', event.detail);
353
+ if (selected.defaultPrevented) event.preventDefault();
354
+ },
355
+ onflyoutchange: (event) => this.emit('flyoutchange', event.detail)
356
+ });
357
+ this._listenMode(railHost, 'zx-select', (event) => event.stopPropagation());
358
+ this._listenMode(railHost, 'zx-flyoutchange', (event) => event.stopPropagation());
359
+ }
360
+
361
+ /** @param {EventTarget} target @param {string} type @param {EventListener} listener @returns {void} */
362
+ _listenMode(target, type, listener) {
363
+ target.addEventListener(type, listener, { signal: this._modeAbort.signal });
364
+ }
365
+
229
366
  /** @returns {void} */
230
367
  _renderTree() {
368
+ if (!this.refs.list) return;
231
369
  const focused = this.refs.list.contains(document.activeElement)
232
- ? /** @type {Element} */ (document.activeElement).closest('[data-app-nav-id]')?.getAttribute('data-app-nav-id')
370
+ ? /** @type {Element} */ (document.activeElement).closest('[data-app-nav-id]')
371
+ ?.getAttribute('data-app-nav-id')
233
372
  : null;
234
373
  this.refs.list.replaceChildren(...this._items.map((item) => this._treeRow(item, 1)));
235
374
  this._syncActive();
@@ -247,7 +386,14 @@ export class AppSidebar extends Component {
247
386
  const key = String(item.id);
248
387
  const expanded = hasChildren && this._expanded.has(key);
249
388
  const childId = hasChildren ? uid('zx-app-sidebar-branch') : null;
250
- const control = navControl(item, hasChildren, expanded, childId);
389
+ const visual = appNavIcon(item, this.options.renderIcon, {
390
+ orientation: this._orientation,
391
+ collapsed: false,
392
+ side: this._side,
393
+ level,
394
+ location: level === 1 ? 'root' : 'inline'
395
+ });
396
+ const control = navControl(item, hasChildren, expanded, childId, visual);
251
397
  const row = h('li', {
252
398
  class: 'zx-app-sidebar__row',
253
399
  dataset: { level }
@@ -262,7 +408,7 @@ export class AppSidebar extends Component {
262
408
  return row;
263
409
  }
264
410
 
265
- /** Handles dynamic tree controls without retaining detached nodes after a rerender. @param {Event} event @returns {void} */
411
+ /** @param {Event} event @returns {void} */
266
412
  _onTreeClick(event) {
267
413
  const control = /** @type {Element|null} */ (event.target)?.closest?.('[data-app-nav-id]');
268
414
  if (!control || !this.refs.list.contains(control)) return;
@@ -275,6 +421,43 @@ export class AppSidebar extends Component {
275
421
  else this._select(item, event);
276
422
  }
277
423
 
424
+ /** @param {KeyboardEvent} event @returns {void} */
425
+ _onTreeKeydown(event) {
426
+ const current = /** @type {Element|null} */ (event.target)?.closest?.('[data-app-nav-id]');
427
+ if (!(current instanceof HTMLElement)) return;
428
+ const item = findItem(this._items, current.getAttribute('data-app-nav-id'));
429
+ const controls = visibleTreeControls(this.refs.list);
430
+ const index = controls.indexOf(current);
431
+ if (event.key === 'ArrowRight' && item?.children?.length) {
432
+ event.preventDefault();
433
+ if (!this._expanded.has(String(item.id))) this.toggleBranch(item.id);
434
+ else visibleTreeControls(current.parentElement?.querySelector('.zx-app-sidebar__sublist'))[0]?.focus();
435
+ return;
436
+ }
437
+ if (event.key === 'ArrowLeft') {
438
+ if (item?.children?.length && this._expanded.has(String(item.id))) {
439
+ event.preventDefault();
440
+ this.toggleBranch(item.id);
441
+ return;
442
+ }
443
+ const parentList = current.closest('.zx-app-sidebar__sublist');
444
+ const parentControl = parentList?.parentElement?.querySelector(':scope > [data-app-nav-id]');
445
+ if (parentControl instanceof HTMLElement) {
446
+ event.preventDefault();
447
+ parentControl.focus();
448
+ }
449
+ return;
450
+ }
451
+ let nextIndex;
452
+ if (event.key === 'Home') nextIndex = 0;
453
+ else if (event.key === 'End') nextIndex = controls.length - 1;
454
+ else if (event.key === 'ArrowDown') nextIndex = Math.min(controls.length - 1, index + 1);
455
+ else if (event.key === 'ArrowUp') nextIndex = Math.max(0, index - 1);
456
+ else return;
457
+ event.preventDefault();
458
+ controls[nextIndex]?.focus();
459
+ }
460
+
278
461
  /** @param {AppNavItem} item @param {Event} event @returns {void} */
279
462
  _select(item, event) {
280
463
  if (item.disabled) {
@@ -297,15 +480,15 @@ export class AppSidebar extends Component {
297
480
 
298
481
  /** @returns {void} */
299
482
  _syncActive() {
483
+ if (!this.refs.list) return;
300
484
  const activeId = this._active == null ? null : String(this._active);
301
485
  for (const control of this.refs.list.querySelectorAll('[data-app-nav-id]')) {
302
486
  const item = findItem(this._items, control.getAttribute('data-app-nav-id'));
303
487
  const direct = activeId !== null && control.getAttribute('data-app-nav-id') === activeId;
304
488
  const descendant = activeId !== null && item
305
489
  ? containsItemId(item.children ?? [], activeId) : false;
306
- if (direct) {
307
- control.setAttribute('aria-current', 'page');
308
- } else control.removeAttribute('aria-current');
490
+ if (direct) control.setAttribute('aria-current', 'page');
491
+ else control.removeAttribute('aria-current');
309
492
  if (descendant) control.dataset.activeDescendant = 'true';
310
493
  else delete control.dataset.activeDescendant;
311
494
  }
@@ -314,13 +497,15 @@ export class AppSidebar extends Component {
314
497
  /** @returns {void} */
315
498
  _reconcileExpanded() {
316
499
  const branchIds = new Set();
317
- walkItems(this._items, (item) => { if (item.children?.length) branchIds.add(String(item.id)); });
500
+ walkItems(this._items, (item) => {
501
+ if (item.children?.length) branchIds.add(String(item.id));
502
+ });
318
503
  for (const id of this._expanded) if (!branchIds.has(id)) this._expanded.delete(id);
319
504
  }
320
505
  }
321
506
 
322
- /** @param {AppNavItem} item @param {boolean} disclosure @param {boolean} expanded @param {string|null} controls @returns {HTMLElement} */
323
- function navControl(item, disclosure, expanded, controls) {
507
+ /** @param {AppNavItem} item @param {boolean} disclosure @param {boolean} expanded @param {string|null} controls @param {Node|null} visual @returns {HTMLElement} */
508
+ function navControl(item, disclosure, expanded, controls, visual) {
324
509
  const tag = item.href && !disclosure ? 'a' : 'button';
325
510
  return /** @type {HTMLElement} */ (h(tag, {
326
511
  class: 'zx-app-sidebar__item',
@@ -332,14 +517,21 @@ function navControl(item, disclosure, expanded, controls) {
332
517
  ariaControls: controls,
333
518
  dataset: { appNavId: String(item.id) }
334
519
  },
335
- item.icon ? h('span', { class: 'zx-app-sidebar__item-icon', ariaHidden: 'true' }, icon(item.icon)) : null,
520
+ visual ? h('span', { class: 'zx-app-sidebar__item-icon', ariaHidden: 'true' }, visual) : null,
336
521
  h('span', { class: 'zx-app-sidebar__item-label' }, item.label),
337
522
  item.badge != null ? h('span', { class: 'zx-app-sidebar__item-badge' }, String(item.badge)) : null,
338
523
  disclosure ? h('span', { class: 'zx-app-sidebar__item-disclosure', ariaHidden: 'true' },
339
524
  icon(expanded ? 'chevron-down' : 'chevron-right')) : null));
340
525
  }
341
526
 
342
- /** Returns a control or the nearest owning disclosure when that control is inside a collapsed branch. @param {Element|null} control @returns {HTMLElement|null} */
527
+ /** @param {Element|null|undefined} root @returns {HTMLElement[]} */
528
+ function visibleTreeControls(root) {
529
+ if (!root) return [];
530
+ return /** @type {HTMLElement[]} */ ([...root.querySelectorAll('[data-app-nav-id]')]
531
+ .filter((control) => !control.closest('.zx-app-sidebar__sublist[hidden]')));
532
+ }
533
+
534
+ /** Returns a control or its nearest owning disclosure when inside a collapsed branch. @param {Element|null} control @returns {HTMLElement|null} */
343
535
  function visibleTreeControl(control) {
344
536
  let candidate = /** @type {HTMLElement|null} */ (control);
345
537
  while (candidate) {
@@ -350,7 +542,7 @@ function visibleTreeControl(control) {
350
542
  return null;
351
543
  }
352
544
 
353
- /** @param {AppNavItem[]} items @param {string|number} id @returns {AppNavItem|null} */
545
+ /** @param {AppNavItem[]} items @param {string|number|null} id @returns {AppNavItem|null} */
354
546
  function findItem(items, id) {
355
547
  for (const item of items) {
356
548
  if (String(item.id) === String(id)) return item;
@@ -376,13 +568,21 @@ function containsItemId(items, id) {
376
568
  /** @param {Element} target @param {unknown} content @returns {void} */
377
569
  function appendSlot(target, content) {
378
570
  if (content == null) return;
379
- if (typeof content === 'string' || typeof content === 'number') target.append(document.createTextNode(String(content)));
380
- else if (typeof content === 'object' && typeof content.toElement === 'function') {
571
+ if (typeof content === 'string' || typeof content === 'number') {
572
+ target.append(document.createTextNode(String(content)));
573
+ } else if (typeof content === 'object' && typeof content.toElement === 'function') {
381
574
  const element = content.toElement();
382
575
  if (element) target.append(element);
383
576
  } else if (typeof content === 'object' && typeof content.nodeType === 'number') target.append(content);
384
577
  }
385
578
 
579
+ /** @param {'vertical'|'horizontal'} orientation @param {unknown} side @returns {'left'|'right'|'top'|'bottom'} */
580
+ function normalizeSide(orientation, side) {
581
+ if (orientation === 'horizontal') return side === 'bottom' ? 'bottom' : 'top';
582
+ return side === 'right' ? 'right' : 'left';
583
+ }
584
+
386
585
  /** Fired when a destination/action is activated. @event AppSidebar#select @type {CustomEvent<AppNavSelectDetail>} */
387
586
  /** 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>}>} */
388
587
  /** Fired when expanded/minimized presentation changes. @event AppSidebar#collapsechange @type {CustomEvent<{collapsed: boolean}>} */
588
+ /** Fired when a minimized flyout changes. @event AppSidebar#flyoutchange @type {CustomEvent<{item: AppNavItem, id: string|number, open: boolean}>} */