@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.59 → 2.0.0-next.60

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 (57) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +5557 -4305
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +1204 -25
  4. package/dist/components/navigation-item/navigation-item.css.js +5 -0
  5. package/dist/components/navigation-item/navigation-item.css.js.map +1 -1
  6. package/dist/components/navigation-item/navigation-item.d.ts +18 -0
  7. package/dist/components/navigation-item/navigation-item.d.ts.map +1 -1
  8. package/dist/components/navigation-item/navigation-item.js +49 -1
  9. package/dist/components/navigation-item/navigation-item.js.map +1 -1
  10. package/dist/components/navigation-item-group/navigation-item-group.css.js +8 -0
  11. package/dist/components/navigation-item-group/navigation-item-group.css.js.map +1 -1
  12. package/dist/components/navigation-item-group/navigation-item-group.d.ts +22 -0
  13. package/dist/components/navigation-item-group/navigation-item-group.d.ts.map +1 -1
  14. package/dist/components/navigation-item-group/navigation-item-group.js +63 -1
  15. package/dist/components/navigation-item-group/navigation-item-group.js.map +1 -1
  16. package/dist/components/navigation-menu/navigation-menu.d.ts +29 -1
  17. package/dist/components/navigation-menu/navigation-menu.d.ts.map +1 -1
  18. package/dist/components/navigation-menu/navigation-menu.js +105 -0
  19. package/dist/components/navigation-menu/navigation-menu.js.map +1 -1
  20. package/dist/components/tree-navigation/tree-navigation.css.js +18 -0
  21. package/dist/components/tree-navigation/tree-navigation.css.js.map +1 -0
  22. package/dist/components/tree-navigation/tree-navigation.d.ts +74 -0
  23. package/dist/components/tree-navigation/tree-navigation.d.ts.map +1 -0
  24. package/dist/components/tree-navigation/tree-navigation.js +120 -0
  25. package/dist/components/tree-navigation/tree-navigation.js.map +1 -0
  26. package/dist/components/tree-navigation-group/tree-navigation-group.css.js +22 -0
  27. package/dist/components/tree-navigation-group/tree-navigation-group.css.js.map +1 -0
  28. package/dist/components/tree-navigation-group/tree-navigation-group.d.ts +94 -0
  29. package/dist/components/tree-navigation-group/tree-navigation-group.d.ts.map +1 -0
  30. package/dist/components/tree-navigation-group/tree-navigation-group.js +116 -0
  31. package/dist/components/tree-navigation-group/tree-navigation-group.js.map +1 -0
  32. package/dist/components/tree-navigation-item/tree-navigation-item.css.js +429 -0
  33. package/dist/components/tree-navigation-item/tree-navigation-item.css.js.map +1 -0
  34. package/dist/components/tree-navigation-item/tree-navigation-item.d.ts +160 -0
  35. package/dist/components/tree-navigation-item/tree-navigation-item.d.ts.map +1 -0
  36. package/dist/components/tree-navigation-item/tree-navigation-item.js +208 -0
  37. package/dist/components/tree-navigation-item/tree-navigation-item.js.map +1 -0
  38. package/dist/internal/tree-roving-navigator.d.ts +71 -0
  39. package/dist/internal/tree-roving-navigator.d.ts.map +1 -0
  40. package/dist/internal/tree-roving-navigator.js +172 -0
  41. package/dist/internal/tree-roving-navigator.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/components/navigation-item/navigation-item.css +5 -0
  44. package/src/components/navigation-item/navigation-item.ts +56 -1
  45. package/src/components/navigation-item-group/navigation-item-group.css +8 -0
  46. package/src/components/navigation-item-group/navigation-item-group.ts +71 -1
  47. package/src/components/navigation-menu/navigation-menu.stories.ts +54 -0
  48. package/src/components/navigation-menu/navigation-menu.ts +151 -0
  49. package/src/components/tree-navigation/tree-navigation.css +7 -0
  50. package/src/components/tree-navigation/tree-navigation.stories.ts +162 -0
  51. package/src/components/tree-navigation/tree-navigation.ts +188 -0
  52. package/src/components/tree-navigation-group/tree-navigation-group.css +11 -0
  53. package/src/components/tree-navigation-group/tree-navigation-group.ts +157 -0
  54. package/src/components/tree-navigation-item/tree-navigation-item.css +258 -0
  55. package/src/components/tree-navigation-item/tree-navigation-item.stories.ts +119 -0
  56. package/src/components/tree-navigation-item/tree-navigation-item.ts +307 -0
  57. package/src/internal/tree-roving-navigator.ts +231 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tree-roving-navigator.js","sources":["../../src/internal/tree-roving-navigator.ts"],"sourcesContent":["import {ObcTreeNavigationItem} from '../components/tree-navigation-item/tree-navigation-item.js';\n\n/**\n * Host-specific row resolution. The two tree hosts differ only in row tags,\n * expand-state storage, and DOM shape, so injecting these hooks lets one\n * navigator drive both.\n */\nexport interface TreeRovingAdapter<Row extends HTMLElement = HTMLElement> {\n /** Top-level rows, in document order. */\n getRows(): Row[];\n /** Direct child rows of a row, in document order. */\n childRows(row: Row): Row[];\n isGroup(row: Row): boolean;\n isExpanded(row: Row): boolean;\n setExpanded(row: Row, expanded: boolean): void;\n /** The focusable `obc-tree-navigation-item` for a row (a leaf is itself). */\n innerItem(row: Row): ObcTreeNavigationItem | null;\n /** Defaults to false when omitted. */\n isDisabled?(row: Row): boolean;\n}\n\n/**\n * Implements the [WAI-ARIA Tree View](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/)\n * keyboard pattern for a tree host: a single tab stop (roving tabindex) plus\n * Up/Down, Left/Right (collapse/expand → parent/child), and Home/End. Type-ahead\n * and `*` are intentionally out of scope.\n *\n * The host owns the DOM and the event wiring; this class owns the navigation\n * logic. The host calls {@link handleKeydown} from its `keydown` listener and\n * {@link refresh} whenever the structure changes, and supplies a\n * {@link TreeRovingAdapter} for row resolution. Focus only moves on user keys;\n * `refresh` re-points the roving tabindex without stealing focus.\n */\nexport class TreeRovingNavigator<Row extends HTMLElement = HTMLElement> {\n private readonly adapter: TreeRovingAdapter<Row>;\n private readonly host: HTMLElement;\n\n /** The row that currently holds the single tab stop (roving tabindex). */\n private activeRow?: Row;\n\n constructor(host: HTMLElement, adapter: TreeRovingAdapter<Row>) {\n this.adapter = adapter;\n this.host = host;\n }\n\n private isDisabled(row: Row): boolean {\n return this.adapter.isDisabled?.(row) ?? false;\n }\n\n /**\n * Depth-first list of every visible row: descend into a group only when it is\n * expanded. Disabled rows are included so {@link refresh} can re-point to a\n * visible ancestor; arrow navigation filters them out via {@link navigableRows}.\n */\n private visibleRows(): Row[] {\n const out: Row[] = [];\n const walk = (rows: Row[]) => {\n for (const row of rows) {\n out.push(row);\n if (this.adapter.isGroup(row) && this.adapter.isExpanded(row)) {\n walk(this.adapter.childRows(row));\n }\n }\n };\n walk(this.adapter.getRows());\n return out;\n }\n\n /** Visible rows that arrow navigation can land on (enabled rows). */\n private navigableRows(): Row[] {\n return this.visibleRows().filter((row) => !this.isDisabled(row));\n }\n\n /** The parent row of a row within this tree, or undefined for a top-level row. */\n private parentRow(target: Row): Row | undefined {\n let found: Row | undefined;\n const walk = (rows: Row[], parent?: Row) => {\n for (const row of rows) {\n if (row === target) {\n found = parent;\n return;\n }\n if (this.adapter.isGroup(row)) {\n walk(this.adapter.childRows(row), row);\n if (found !== undefined) return;\n }\n }\n };\n walk(this.adapter.getRows(), undefined);\n return found;\n }\n\n /**\n * Rebuild the roving tabindex: exactly one navigable row is `focusable`. Keeps\n * the active row if still navigable; else falls back to its nearest navigable\n * ancestor, else the first navigable row. Never moves focus.\n */\n refresh(): void {\n const navigable = this.navigableRows();\n if (navigable.length === 0) {\n this.activeRow = undefined;\n return;\n }\n\n let next: Row | undefined;\n if (this.activeRow && navigable.includes(this.activeRow)) {\n next = this.activeRow;\n } else if (this.activeRow) {\n let ancestor = this.parentRow(this.activeRow);\n while (ancestor && !navigable.includes(ancestor)) {\n ancestor = this.parentRow(ancestor);\n }\n next = ancestor ?? navigable[0];\n } else {\n next = navigable[0];\n }\n\n this.setActiveRow(next, false);\n }\n\n private setActiveRow(row: Row, moveFocus: boolean): void {\n this.activeRow = row;\n for (const candidate of this.visibleRows()) {\n const item = this.adapter.innerItem(candidate);\n if (item) item.focusable = candidate === row;\n }\n if (!moveFocus) return;\n // A row's header may render asynchronously (e.g. right after expanding a\n // group), so fall back to a microtask if it isn't present yet.\n const item = this.adapter.innerItem(row);\n if (item) item.focus();\n else queueMicrotask(() => this.adapter.innerItem(row)?.focus());\n }\n\n /**\n * The originating tree row for a keydown. A group's header lives in the\n * group's own shadow root, so the host-root check resolves a header to its\n * group rather than the inner header item.\n */\n private rowFromEvent(event: KeyboardEvent): Row | undefined {\n // Read the root live: the navigator is constructed before the host connects.\n const root = this.host.getRootNode();\n for (const target of event.composedPath()) {\n if (\n target instanceof HTMLElement &&\n target.getRootNode() === root &&\n this.isRow(target)\n ) {\n return target as Row;\n }\n }\n return undefined;\n }\n\n /** Whether an element is one of this tree's rows (group or leaf). */\n private isRow(el: HTMLElement): boolean {\n const tops = this.adapter.getRows();\n const stack = [...tops];\n while (stack.length) {\n const row = stack.pop()!;\n if (row === el) return true;\n if (this.adapter.isGroup(row)) stack.push(...this.adapter.childRows(row));\n }\n return false;\n }\n\n /** Handle a `keydown`; returns true if the key was consumed. */\n handleKeydown(event: KeyboardEvent): boolean {\n const row = this.rowFromEvent(event);\n if (!row) return false;\n\n switch (event.key) {\n case 'ArrowDown':\n this.moveByOffset(row, 1);\n return true;\n case 'ArrowUp':\n this.moveByOffset(row, -1);\n return true;\n case 'ArrowRight':\n this.onArrowRight(row);\n return true;\n case 'ArrowLeft':\n this.onArrowLeft(row);\n return true;\n case 'Home':\n this.moveToEdge(true);\n return true;\n case 'End':\n this.moveToEdge(false);\n return true;\n default:\n return false;\n }\n }\n\n private moveByOffset(row: Row, offset: number): void {\n const rows = this.navigableRows();\n const index = rows.indexOf(row);\n if (index === -1) return;\n const target = rows[index + offset];\n if (target) this.setActiveRow(target, true);\n }\n\n private moveToEdge(first: boolean): void {\n const rows = this.navigableRows();\n if (rows.length === 0) return;\n this.setActiveRow(first ? rows[0] : rows[rows.length - 1], true);\n }\n\n private onArrowRight(row: Row): void {\n if (!this.adapter.isGroup(row)) return;\n if (!this.adapter.isExpanded(row)) {\n this.adapter.setExpanded(row, true);\n // Re-point the roving set for hosts that don't observe expansion themselves.\n this.refresh();\n return;\n }\n const child = this.adapter.childRows(row).find((c) => !this.isDisabled(c));\n if (child) this.setActiveRow(child, true);\n }\n\n private onArrowLeft(row: Row): void {\n if (this.adapter.isGroup(row) && this.adapter.isExpanded(row)) {\n this.adapter.setExpanded(row, false);\n this.refresh();\n return;\n }\n const parent = this.parentRow(row);\n if (parent) this.setActiveRow(parent, true);\n }\n}\n"],"names":["item"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oicl/openbridge-webcomponents-full-bundle",
3
- "version": "2.0.0-next.59",
3
+ "version": "2.0.0-next.60",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -185,3 +185,8 @@
185
185
  min-width: 0;
186
186
  }
187
187
  }
188
+
189
+ obc-tree-navigation-item.tree {
190
+ display: block;
191
+ width: 100%;
192
+ }
@@ -6,6 +6,12 @@ import {ifDefined} from 'lit/directives/if-defined.js';
6
6
  import '../../icons/icon-arrow-flyout-google.js';
7
7
  import {ObcNavigationMenuVariant} from '../navigation-menu/navigation-menu.js';
8
8
  import {customElement} from '../../decorator.js';
9
+ import '../tree-navigation-item/tree-navigation-item.js';
10
+ import {
11
+ TreeBranchType,
12
+ TreeTerminalType,
13
+ } from '../tree-navigation-item/tree-navigation-item.js';
14
+ import {BadgeType} from '../badge/badge.js';
9
15
 
10
16
  enum NavigationItemRole {
11
17
  Button = 'button',
@@ -142,8 +148,32 @@ export class ObcNavigationItem extends LitElement {
142
148
 
143
149
  @property({type: Boolean}) hasTrailingIcon = false;
144
150
 
151
+ /** Set by `obc-navigation-menu` in its Tree variant — renders the row as a tree item. */
152
+ @property({type: Boolean}) treeMode = false;
153
+
154
+ /** Indentation columns for tree mode, assigned by `obc-navigation-menu`. */
155
+ @property({type: Array}) treeBranches: TreeBranchType[] = [];
156
+
157
+ /**
158
+ * Terminal type for the row in the Tree variant — one of `regular` (default),
159
+ * `aggregated-header`, or `group-header`. Has no effect in the flat variants.
160
+ */
161
+ @property({type: String}) terminalType: string = TreeTerminalType.regular;
162
+
163
+ /** Whether a trailing alert counter badge is shown (Tree variant only). */
164
+ @property({type: Boolean}) hasAlertBadge = false;
165
+
166
+ /** The number shown in the alert badge when `hasAlertBadge` is true (Tree variant only). */
167
+ @property({type: Number}) alertCount = 0;
168
+
169
+ /** The severity/type of the alert badge — one of the `obc-badge` types (Tree variant only). */
170
+ @property({type: String}) alertType: string = BadgeType.alarm;
171
+
145
172
  @query('a') private anchorElement?: HTMLAnchorElement;
146
173
 
174
+ // In tree mode the row renders an `obc-tree-navigation-item` instead of an `<a>`.
175
+ @query('obc-tree-navigation-item') private treeItemElement?: HTMLElement;
176
+
147
177
  /**
148
178
  * Fired when the navigation item is clicked (either as a link or button).
149
179
  * @fires click {CustomEvent<void>}
@@ -165,7 +195,7 @@ export class ObcNavigationItem extends LitElement {
165
195
  }
166
196
 
167
197
  public override focus(options?: FocusOptions): void {
168
- this.anchorElement?.focus(options);
198
+ (this.treeItemElement ?? this.anchorElement)?.focus(options);
169
199
  }
170
200
 
171
201
  private getItemRole(): NavigationItemRole | undefined {
@@ -188,6 +218,31 @@ export class ObcNavigationItem extends LitElement {
188
218
  }
189
219
 
190
220
  override render() {
221
+ if (this.treeMode) {
222
+ // Delegate the whole row to the tree item: it owns focus, keyboard
223
+ // activation, the checked-inert behavior, and href navigation. Forward its
224
+ // activation as this item's own `click` so selection wiring is unchanged.
225
+ return html`
226
+ <obc-tree-navigation-item
227
+ class="tree"
228
+ .label=${this.label}
229
+ .branches=${this.treeBranches}
230
+ ?checked=${this.checked}
231
+ .hasLeadingIcon=${this.hasIcon}
232
+ .href=${this.href}
233
+ .terminalType=${this.terminalType}
234
+ ?hasAlertBadge=${this.hasAlertBadge}
235
+ .alertCount=${this.alertCount}
236
+ .alertType=${this.alertType}
237
+ @click=${this.onClick}
238
+ >
239
+ ${this.hasIcon
240
+ ? html`<slot name="icon" slot="icon"></slot>`
241
+ : nothing}
242
+ </obc-tree-navigation-item>
243
+ `;
244
+ }
245
+
191
246
  const showFlyout =
192
247
  this.group && this.variant !== ObcNavigationMenuVariant.IconOnly;
193
248
  const isCompact = this.variant === ObcNavigationMenuVariant.Compact;
@@ -100,3 +100,11 @@
100
100
  display: flex;
101
101
  flex-direction: column;
102
102
  }
103
+
104
+ [part="children"] {
105
+ display: block;
106
+ }
107
+
108
+ [part="children"][hidden] {
109
+ display: none;
110
+ }
@@ -4,6 +4,12 @@ import compentStyle from './navigation-item-group.css?inline';
4
4
  import {ObcNavigationMenuVariant} from '../navigation-menu/navigation-menu.js';
5
5
  import {classMap} from 'lit/directives/class-map.js';
6
6
  import {customElement} from '../../decorator.js';
7
+ import '../tree-navigation-item/tree-navigation-item.js';
8
+ import {
9
+ TreeBranchType,
10
+ TreeTerminalType,
11
+ } from '../tree-navigation-item/tree-navigation-item.js';
12
+ import {BadgeType} from '../badge/badge.js';
7
13
 
8
14
  /**
9
15
  * `<obc-navigation-item-group>` – A collapsible navigation group component for organizing related navigation items under a single expandable label.
@@ -90,9 +96,47 @@ export class ObcNavigationItemGroup extends LitElement {
90
96
 
91
97
  @property({type: Boolean}) hasIcon = false;
92
98
 
99
+ /** Set by `obc-navigation-menu` in its Tree variant — renders the group as a tree row. */
100
+ @property({type: Boolean}) treeMode = false;
101
+
102
+ /** Indentation columns for tree mode, assigned by `obc-navigation-menu`. */
103
+ @property({type: Array}) treeBranches: TreeBranchType[] = [];
104
+
105
+ /**
106
+ * Terminal type for the group header in the Tree variant — one of `regular`
107
+ * (default), `aggregated-header`, or `group-header`. No effect in flat variants.
108
+ */
109
+ @property({type: String}) terminalType: string = TreeTerminalType.regular;
110
+
111
+ /** Whether a trailing alert counter badge is shown on the header (Tree variant only). */
112
+ @property({type: Boolean}) hasAlertBadge = false;
113
+
114
+ /** The number shown in the header's alert badge when `hasAlertBadge` is true (Tree variant only). */
115
+ @property({type: Number}) alertCount = 0;
116
+
117
+ /** The severity/type of the header's alert badge — one of the `obc-badge` types (Tree variant only). */
118
+ @property({type: String}) alertType: string = BadgeType.alarm;
119
+
120
+ /** Whether the group starts expanded. Useful for trees that open by default. */
121
+ @property({type: Boolean}) defaultOpen = false;
122
+
93
123
  @state() private openContainer = false;
94
124
 
95
- @query('obc-navigation-item') private groupItem?: HTMLElement;
125
+ // Flat mode renders an `obc-navigation-item` header; tree mode renders an
126
+ // `obc-tree-navigation-item`. Match whichever is present so `focus()` works in both.
127
+ @query('obc-navigation-item, obc-tree-navigation-item')
128
+ private groupItem?: HTMLElement;
129
+
130
+ override firstUpdated() {
131
+ if (this.defaultOpen) {
132
+ this.openContainer = true;
133
+ }
134
+ }
135
+
136
+ /** Whether the group is currently open (its children are disclosed). */
137
+ public get expanded(): boolean {
138
+ return this.openContainer;
139
+ }
96
140
 
97
141
  private onClickGroup() {
98
142
  if (this.openContainer) {
@@ -123,6 +167,32 @@ export class ObcNavigationItemGroup extends LitElement {
123
167
  }
124
168
 
125
169
  override render() {
170
+ if (this.treeMode) {
171
+ return html`
172
+ <obc-tree-navigation-item
173
+ part="header"
174
+ .label=${this.label}
175
+ .branches=${this.treeBranches}
176
+ expandable
177
+ ?expanded=${this.openContainer}
178
+ ?checked=${this.checked}
179
+ .hasLeadingIcon=${this.hasIcon}
180
+ .terminalType=${this.terminalType}
181
+ ?hasAlertBadge=${this.hasAlertBadge}
182
+ .alertCount=${this.alertCount}
183
+ .alertType=${this.alertType}
184
+ @expand-toggle=${this.onClickGroup}
185
+ >
186
+ ${this.hasIcon
187
+ ? html`<slot name="icon" slot="icon"></slot>`
188
+ : nothing}
189
+ </obc-tree-navigation-item>
190
+ <div part="children" role="group" ?hidden=${!this.openContainer}>
191
+ <slot></slot>
192
+ </div>
193
+ `;
194
+ }
195
+
126
196
  return html`
127
197
  <obc-navigation-item
128
198
  @click=${this.onClickGroup}
@@ -252,3 +252,57 @@ export const TestDynamicElementsInSpan: Story = {
252
252
  await expect(newItem.variant).toBe(ObcNavigationMenuVariant.Compact);
253
253
  },
254
254
  };
255
+
256
+ export const Tree: Story = {
257
+ args: {
258
+ variant: ObcNavigationMenuVariant.Tree,
259
+ },
260
+ render: (args) => {
261
+ return html`
262
+ <obc-navigation-menu
263
+ .variant=${args.variant}
264
+ style="position: fixed; top: 0; bottom: 0; left: 0;"
265
+ >
266
+ <obc-navigation-item-group
267
+ slot="main"
268
+ label="Vessel"
269
+ hasIcon
270
+ defaultOpen
271
+ >
272
+ <obi-placeholder slot="icon"></obi-placeholder>
273
+ <obc-navigation-item-group
274
+ label="Engine room"
275
+ hasIcon
276
+ defaultOpen
277
+ terminalType="aggregated-header"
278
+ >
279
+ <obi-placeholder slot="icon"></obi-placeholder>
280
+ <obc-navigation-item label="Main engine" hasIcon checked>
281
+ <obi-placeholder slot="icon"></obi-placeholder>
282
+ </obc-navigation-item>
283
+ <obc-navigation-item
284
+ label="Cooling system"
285
+ hasIcon
286
+ hasAlertBadge
287
+ .alertCount=${3}
288
+ >
289
+ <obi-placeholder slot="icon"></obi-placeholder>
290
+ </obc-navigation-item>
291
+ </obc-navigation-item-group>
292
+ <obc-navigation-item-group label="Bridge" hasIcon>
293
+ <obi-placeholder slot="icon"></obi-placeholder>
294
+ <obc-navigation-item label="Radar" hasIcon>
295
+ <obi-placeholder slot="icon"></obi-placeholder>
296
+ </obc-navigation-item>
297
+ <obc-navigation-item label="Autopilot" hasIcon>
298
+ <obi-placeholder slot="icon"></obi-placeholder>
299
+ </obc-navigation-item>
300
+ </obc-navigation-item-group>
301
+ <obc-navigation-item label="Deck" hasIcon>
302
+ <obi-placeholder slot="icon"></obi-placeholder>
303
+ </obc-navigation-item>
304
+ </obc-navigation-item-group>
305
+ </obc-navigation-menu>
306
+ `;
307
+ },
308
+ };
@@ -4,6 +4,29 @@ import compentStyle from './navigation-menu.css?inline';
4
4
  import {ObcNavigationItemGroup} from '../navigation-item-group/navigation-item-group.js';
5
5
  import {ObcNavigationItem} from '../navigation-item/navigation-item.js';
6
6
  import {customElement} from '../../decorator.js';
7
+ import {
8
+ ObcTreeNavigationItem,
9
+ TreeBranchType,
10
+ } from '../tree-navigation-item/tree-navigation-item.js';
11
+ import {
12
+ TreeRovingNavigator,
13
+ TreeRovingAdapter,
14
+ } from '../../internal/tree-roving-navigator.js';
15
+
16
+ /** A nav-menu tree row is either a navigation item or a navigation item group. */
17
+ type NavTreeRow = ObcNavigationItem | ObcNavigationItemGroup;
18
+
19
+ const NAV_ITEM_TAG = 'obc-navigation-item';
20
+ const NAV_GROUP_TAG = 'obc-navigation-item-group';
21
+
22
+ function isNavGroup(el: Element): el is ObcNavigationItemGroup {
23
+ return el.tagName.toLowerCase() === NAV_GROUP_TAG;
24
+ }
25
+
26
+ function isNavRow(el: Element): el is NavTreeRow {
27
+ const tag = el.tagName.toLowerCase();
28
+ return tag === NAV_ITEM_TAG || tag === NAV_GROUP_TAG;
29
+ }
7
30
 
8
31
  /**
9
32
  * `ObcNavigationMenuVariant` – Enumerates the available visual and behavioral variants for `<obc-navigation-menu>`.
@@ -12,6 +35,7 @@ import {customElement} from '../../decorator.js';
12
35
  * - `IconOnly`: Compact menu showing only icons (should only be used when no flyouts/submenus are present).
13
36
  * - `IconOnlyLarge`: Icon-only menu variant designed for use when flyouts/submenus are present.
14
37
  * - `Compact`: Space-saving menu with reduced padding and layout.
38
+ * - `Tree`: Hierarchical tree — groups expand inline and rows are indented by depth.
15
39
  *
16
40
  * Use these variants to adapt the navigation menu to different layouts or device sizes.
17
41
  */
@@ -20,6 +44,7 @@ export enum ObcNavigationMenuVariant {
20
44
  IconOnly = 'icon-only', // Should only be used when no flyouts are present in the navigation menu
21
45
  IconOnlyLarge = 'icon-only-large', // Should be used when flyouts are present in the navigation menu
22
46
  Compact = 'compact',
47
+ Tree = 'tree',
23
48
  }
24
49
 
25
50
  /**
@@ -49,6 +74,11 @@ export enum ObcNavigationMenuFlyoutVariant {
49
74
  * - **IconOnly:** Shows only icons for a compact appearance. *Should only be used when no navigation items have sub-items or flyouts.*
50
75
  * - **IconOnlyLarge:** Icon-only mode that supports flyouts/submenus. Use when navigation contains groups or nested items.
51
76
  * - **Compact:** Reduces padding and overall width for a space-saving layout.
77
+ * - **Tree:** Renders the same `obc-navigation-item` / `obc-navigation-item-group`
78
+ * markup as a hierarchical tree. Groups expand inline (instead of as flyouts),
79
+ * rows are indented by depth, multiple branches can stay open at once, and the
80
+ * tree-row presentation (alert badge, terminal marker) is available via the
81
+ * items' `hasAlertBadge`/`alertCount`/`alertType`/`terminalType` properties.
52
82
  * - **Responsive Layout:**
53
83
  * - `smallScreen` property adapts the footer and logo layout for smaller viewports.
54
84
  * - **Slot-based Content:**
@@ -71,6 +101,10 @@ export enum ObcNavigationMenuFlyoutVariant {
71
101
  * - Use the `Full` variant for standard navigation with both icons and labels.
72
102
  * - Use `IconOnly` only when there are no nested groups or flyouts; otherwise, use `IconOnlyLarge` for icon-only navigation with flyout support.
73
103
  * - The `Compact` variant is suitable for layouts with limited space or when a minimal navigation appearance is desired.
104
+ * - Use the `Tree` variant for hierarchical navigation (file trees, nested
105
+ * sections). The existing item/group markup is reused unchanged — only the
106
+ * `variant` changes. Footer and logo slots remain flat. Mark a group with
107
+ * `defaultOpen` to have it start expanded.
74
108
  * - Set `smallScreen` to `true` to optimize the layout for smaller devices or responsive breakpoints.
75
109
  *
76
110
  * **TODO(designer):** Provide additional guidance on when to use each variant and recommended slot content for best usability.
@@ -129,6 +163,7 @@ export class ObcNavigationMenu extends LitElement {
129
163
  * - `icon-only`: Compact, icon-only menu (use only when no flyouts/groups are present).
130
164
  * - `icon-only-large`: Icon-only menu supporting flyouts/groups.
131
165
  * - `compact`: Minimal, space-saving menu.
166
+ * - `tree`: Hierarchical tree — groups expand inline and rows are indented by depth.
132
167
  */
133
168
  @property({type: String}) variant: ObcNavigationMenuVariant =
134
169
  ObcNavigationMenuVariant.Full;
@@ -148,6 +183,59 @@ export class ObcNavigationMenu extends LitElement {
148
183
  private slotObservers: MutationObserver[] = [];
149
184
  @state() private hasFooter = false;
150
185
 
186
+ /**
187
+ * Roving-tabindex + arrow-key navigation for the Tree variant, sharing the
188
+ * navigator that drives `obc-tree-navigation`. Engaged only while
189
+ * `variant === Tree` (see `onTreeKeydown`).
190
+ */
191
+ private readonly treeNavigator = new TreeRovingNavigator<NavTreeRow>(this, {
192
+ getRows: () => this.treeRootRows(),
193
+ childRows: (row) => this.treeChildRows(row),
194
+ isGroup: (row) => isNavGroup(row),
195
+ // Read the group's own synchronous open state, not the shadow header's
196
+ // attribute (which lags a render tick behind a keyboard expand/collapse).
197
+ isExpanded: (row) => isNavGroup(row) && row.expanded,
198
+ setExpanded: (row, expanded) => {
199
+ if (!isNavGroup(row)) return;
200
+ if (expanded) row.open();
201
+ else row.close();
202
+ },
203
+ innerItem: (row) => this.treeInnerItem(row),
204
+ } satisfies TreeRovingAdapter<NavTreeRow>);
205
+
206
+ /** Top-level tree rows of the main slot (mirrors `assignTreeBranches`'s filter). */
207
+ private treeRootRows(): NavTreeRow[] {
208
+ return Array.from(this.children).filter((child): child is NavTreeRow => {
209
+ if (!isNavRow(child)) return false;
210
+ const slot = child.getAttribute('slot');
211
+ return slot === null || slot === 'main';
212
+ });
213
+ }
214
+
215
+ /** Direct tree-row children of a row, in document order. */
216
+ private treeChildRows(row: NavTreeRow): NavTreeRow[] {
217
+ return Array.from(row.children).filter(isNavRow);
218
+ }
219
+
220
+ /** The inline `obc-tree-navigation-item` a tree-mode row renders in its shadow root. */
221
+ private treeInnerItem(row: NavTreeRow): ObcTreeNavigationItem | null {
222
+ return (
223
+ row.shadowRoot?.querySelector<ObcTreeNavigationItem>(
224
+ 'obc-tree-navigation-item'
225
+ ) ?? null
226
+ );
227
+ }
228
+
229
+ private onTreeKeydown = (event: KeyboardEvent): void => {
230
+ if (this.variant !== ObcNavigationMenuVariant.Tree) return;
231
+ if (this.treeNavigator.handleKeydown(event)) event.preventDefault();
232
+ };
233
+
234
+ override connectedCallback(): void {
235
+ super.connectedCallback();
236
+ this.addEventListener('keydown', this.onTreeKeydown);
237
+ }
238
+
151
239
  findAllElements<T extends Element>(
152
240
  el: Element,
153
241
  tag: string,
@@ -212,6 +300,7 @@ export class ObcNavigationMenu extends LitElement {
212
300
  registerGroup(groups: ObcNavigationItemGroup[]) {
213
301
  groups.forEach((group) => {
214
302
  group.addEventListener('open', () => {
303
+ if (this.variant === ObcNavigationMenuVariant.Tree) return;
215
304
  groups.forEach((g) => {
216
305
  if (g !== group) {
217
306
  g.close();
@@ -296,6 +385,7 @@ export class ObcNavigationMenu extends LitElement {
296
385
  override disconnectedCallback(): void {
297
386
  super.disconnectedCallback();
298
387
  this.cleanupSlotObservers();
388
+ this.removeEventListener('keydown', this.onTreeKeydown);
299
389
  }
300
390
 
301
391
  private handleSlotChange() {
@@ -303,7 +393,68 @@ export class ObcNavigationMenu extends LitElement {
303
393
  this.setupSlotObservers();
304
394
  }
305
395
 
396
+ private assignTreeBranches(el: Element, depth: number): void {
397
+ for (const child of el.children) {
398
+ const tag = child.tagName.toLowerCase();
399
+ const isItem = tag === 'obc-navigation-item';
400
+ const isGroup = tag === 'obc-navigation-item-group';
401
+ if (!isItem && !isGroup) continue;
402
+ // Top-level rows are slotted; only treeify the main slot. Nested rows have
403
+ // no slot attribute and always belong to their group's tree.
404
+ if (depth === 0) {
405
+ const slot = child.getAttribute('slot');
406
+ if (slot !== null && slot !== 'main') continue;
407
+ }
408
+ const branches: TreeBranchType[] = Array.from(
409
+ {length: depth},
410
+ () => TreeBranchType.blank
411
+ );
412
+ const row = child as ObcNavigationItem | ObcNavigationItemGroup;
413
+ row.treeMode = true;
414
+ row.treeBranches = branches;
415
+ if (isGroup) {
416
+ this.assignTreeBranches(child, depth + 1);
417
+ }
418
+ }
419
+ }
420
+
421
+ // Reset tree mode on every descendant item/group so switching away from the
422
+ // Tree variant restores the flat rendering.
423
+ private clearTreeMode(el: Element): void {
424
+ for (const child of el.children) {
425
+ const tag = child.tagName.toLowerCase();
426
+ const isItem = tag === 'obc-navigation-item';
427
+ const isGroup = tag === 'obc-navigation-item-group';
428
+ if (!isItem && !isGroup) continue;
429
+ const row = child as ObcNavigationItem | ObcNavigationItemGroup;
430
+ row.treeMode = false;
431
+ row.treeBranches = [];
432
+ if (isGroup) {
433
+ this.clearTreeMode(child);
434
+ }
435
+ }
436
+ }
437
+
306
438
  private setupItems() {
439
+ if (this.variant === ObcNavigationMenuVariant.Tree) {
440
+ this.assignTreeBranches(this, 0);
441
+ // Footer and logo are not part of the tree — keep them as flat full-variant
442
+ // items (and clear any tree mode left over from the main slot's walk).
443
+ (['footer', 'logo'] as const).forEach((slot) => {
444
+ this.findAllItems(this, slot).forEach((item) => {
445
+ item.treeMode = false;
446
+ item.treeBranches = [];
447
+ item.variant = ObcNavigationMenuVariant.Full;
448
+ });
449
+ });
450
+ // Defer so the rows' inline tree-item headers exist before the roving
451
+ // tabindex is assigned to them.
452
+ queueMicrotask(() => this.treeNavigator.refresh());
453
+ return;
454
+ }
455
+
456
+ this.clearTreeMode(this);
457
+
307
458
  const hug =
308
459
  this.variant !== ObcNavigationMenuVariant.Full ||
309
460
  this.flyoutVariant === ObcNavigationMenuFlyoutVariant.Compact;
@@ -0,0 +1,7 @@
1
+ :host {
2
+ display: block;
3
+ }
4
+
5
+ [role="tree"] {
6
+ display: block;
7
+ }