@m3e/nav-menu 1.0.0-rc.1 → 1.0.0-rc.2

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.
@@ -1,325 +0,0 @@
1
- import { css, CSSResultGroup, html, LitElement } from "lit";
2
- import { customElement } from "lit/decorators.js";
3
-
4
- import { DesignToken, FocusController, PressedController, Role, scrollIntoViewIfNeeded } from "@m3e/core";
5
- import { SelectionManager, selectionManager } from "@m3e/core/a11y";
6
-
7
- import { M3eNavMenuItemElement } from "./NavMenuItemElement";
8
-
9
- /**
10
- * @summary
11
- * Presents a hierarchical menu.
12
- *
13
- * @description
14
- * The `m3e-nav-menu` component provides a hierarchical, accessible navigation menu supporting
15
- * nested expandable items, keyboard navigation, and focus management. It is highly customizable
16
- * via slots and CSS custom properties, and is designed for use in sidebars, navigation drawers,
17
- * and complex menu structures.
18
- *
19
- * @example
20
- * The following example illustrates a multilevel navigation menu.
21
- * ```html
22
- * <m3e-nav-menu>
23
- * <m3e-nav-menu-item open>
24
- * <m3e-icon slot="icon" name="rocket_launch"></m3e-icon>
25
- * <span slot="label">Getting Started</span>
26
- * <m3e-nav-menu-item>
27
- * <m3e-icon slot="icon" name="widgets"></m3e-icon>
28
- * <span slot="label">Overview</span>
29
- * </m3e-nav-menu-item>
30
- * <m3e-nav-menu-item>
31
- * <m3e-icon slot="icon" name="package_2"></m3e-icon>
32
- * <span slot="label">Installation</span>
33
- * </m3e-nav-menu-item>
34
- * </m3e-nav-menu-item>
35
- * <m3e-nav-menu-item>
36
- * <span slot="label">Actions</span>
37
- * <m3e-nav-menu-item><span slot="label">Button</span></m3e-nav-menu-item>
38
- * <m3e-nav-menu-item><span slot="label">Icon</span></m3e-nav-menu-item>
39
- * <m3e-nav-menu-item><span slot="label">Icon Button</span></m3e-nav-menu-item>
40
- * </m3e-nav-menu-item>
41
- * </m3e-nav-menu>
42
- * ```
43
- *
44
- * @tag m3e-nav-menu
45
- *
46
- * @slot - Renders the items of the menu.
47
- *
48
- * @cssprop --m3e-nav-menu-padding-top - Top padding for the menu.
49
- * @cssprop --m3e-nav-menu-padding-bottom - Bottom padding for the menu.
50
- * @cssprop --m3e-nav-menu-padding-left - Left padding for the menu.
51
- * @cssprop --m3e-nav-menu-padding-right - Right padding for the menu.
52
- * @cssprop --m3e-nav-menu-divider-margin - Margin for divider elements in the menu.
53
- * @cssprop --m3e-nav-menu-scrollbar-width - Width of the menu scrollbar.
54
- * @cssprop --m3e-nav-menu-scrollbar-color - Color of the menu scrollbar.
55
- */
56
- @customElement("m3e-nav-menu")
57
- export class M3eNavMenuElement extends Role(LitElement, "tree") {
58
- /** The styles of the element. */
59
- static override styles: CSSResultGroup = css`
60
- :host {
61
- display: flex;
62
- flex-direction: column;
63
- outline: none;
64
- overflow-y: auto;
65
- overflow-x: hidden;
66
- position: relative;
67
- min-height: 0;
68
- padding-block-start: var(--m3e-nav-menu-padding-top, 0.5rem);
69
- padding-block-end: var(--m3e-nav-menu-padding-bottom, 0.5rem);
70
- padding-inline-start: var(--m3e-nav-menu-padding-left, 0.75rem);
71
- padding-inline-end: var(--m3e-nav-menu-padding-right, 0.75rem);
72
- scrollbar-width: ${DesignToken.scrollbar.width};
73
- scrollbar-color: ${DesignToken.scrollbar.color};
74
- }
75
- ::slotted(m3e-divider) {
76
- margin-block: var(--m3e-nav-menu-divider-margin, 0.25rem);
77
- }
78
- `;
79
-
80
- /** @private */ private static __nextId = 0;
81
- /** @private */ #ignoreFocusVisible = false;
82
- /** @private */ #ignoreFocus = false;
83
-
84
- /** @private */
85
- readonly [selectionManager] = new SelectionManager<M3eNavMenuItemElement>()
86
- .withVerticalOrientation()
87
- .withHomeAndEnd()
88
- .withTypeahead()
89
- .withSkipPredicate((x) => x.disabled || !x.visible)
90
- .disableRovingTabIndex()
91
- .onActiveItemChange(() => {
92
- if (this[selectionManager].activeItem) {
93
- this.#activateItem(this[selectionManager].activeItem);
94
- }
95
- })
96
- .onSelectedItemsChange(() => {
97
- const selected = this.selected;
98
- for (const item of this.items) {
99
- if (item !== selected) {
100
- this.#updateItemFocusVisible(item, false, false);
101
- }
102
- }
103
- });
104
-
105
- /** @private */ readonly #keyDownHandler = (e: KeyboardEvent) => this.#handleKeyDown(e);
106
- /** @private */ readonly #keyUpHandler = (e: KeyboardEvent) => this.#handleKeyUp(e);
107
- /** @private */ readonly #pointerDownHandler = (e: Event) => this.#handlePointerDown(e);
108
-
109
- constructor() {
110
- super();
111
-
112
- new PressedController(this, { callback: (pressed) => (this.#ignoreFocus = pressed) });
113
- new FocusController(this, {
114
- callback: () => {
115
- if (!this.#ignoreFocus) {
116
- this.#updateFocusVisible();
117
- }
118
- },
119
- });
120
- }
121
-
122
- /** The selected item of the menu. */
123
- get selected(): M3eNavMenuItemElement | null {
124
- return this[selectionManager].selectedItems[0] ?? null;
125
- }
126
-
127
- /** All the items of the menu. */
128
- get items(): readonly M3eNavMenuItemElement[] {
129
- return this[selectionManager].items;
130
- }
131
-
132
- /**
133
- * Expands the specified items, or all items if no items are provided.
134
- * @param {M3eNavMenuItemElement | undefined} items The items to expand.
135
- */
136
- expand(items?: M3eNavMenuItemElement[]): void {
137
- (items ?? this[selectionManager].items).forEach((x) => x.expand());
138
- }
139
-
140
- /**
141
- * Collapses the specified items, or all items if no items are provided.
142
- * @param {M3eNavMenuItemElement | undefined} items The items to collapse.
143
- */
144
- collapse(items?: M3eNavMenuItemElement[]): void {
145
- (items ?? this[selectionManager].items).forEach((x) => x.collapse());
146
-
147
- const activeItem = this[selectionManager].activeItem;
148
- if (activeItem && !activeItem.visible) {
149
- for (let parent = activeItem.parentItem; parent; parent = parent.parentItem) {
150
- if (parent.visible) {
151
- this[selectionManager].setActiveItem(parent);
152
- break;
153
- }
154
- }
155
- }
156
- }
157
-
158
- /** @inheritdoc */
159
- override connectedCallback(): void {
160
- super.connectedCallback();
161
-
162
- this.setAttribute("tabindex", "0");
163
-
164
- this.addEventListener("keydown", this.#keyDownHandler);
165
- this.addEventListener("keyup", this.#keyUpHandler);
166
- this.addEventListener("pointerdown", this.#pointerDownHandler);
167
- }
168
-
169
- /** @inheritdoc */
170
- override disconnectedCallback(): void {
171
- super.disconnectedCallback();
172
-
173
- this.removeEventListener("keydown", this.#keyDownHandler);
174
- this.removeEventListener("keyup", this.#keyUpHandler);
175
- this.removeEventListener("pointerdown", this.#pointerDownHandler);
176
- }
177
-
178
- /** @inheritdoc */
179
- protected override render(): unknown {
180
- return html`<slot @slotchange="${this.#handleSlotChange}"></slot>`;
181
- }
182
-
183
- /** @private */
184
- #handleSlotChange(): void {
185
- const { added } = this[selectionManager].setItems([...this.querySelectorAll("m3e-nav-menu-item")]);
186
- for (const item of added) {
187
- item.id = item.id || `m3e-nav-menu-item-${M3eNavMenuElement.__nextId++}`;
188
- }
189
- if (this[selectionManager].activeItem) {
190
- this.setAttribute("aria-activedescendant", this[selectionManager].activeItem.id);
191
- this.#updateFocusVisible();
192
- } else {
193
- this.removeAttribute("aria-activedescendant");
194
- }
195
- }
196
-
197
- /** @private */
198
- #handleKeyDown(e: KeyboardEvent): void {
199
- this.#ignoreFocusVisible = false;
200
- this.#updateFocusVisible();
201
-
202
- const item = this[selectionManager].activeItem;
203
- if (e.defaultPrevented || !item || item.disabled) return;
204
-
205
- switch (e.key) {
206
- case "Enter":
207
- case " ":
208
- e.preventDefault();
209
-
210
- if (item.ripple && !item.ripple.visible) {
211
- item.ripple.show(0, 0, true);
212
- }
213
-
214
- if (item.hasChildItems) {
215
- item.toggle();
216
- } else if (!item.selected) {
217
- this[selectionManager].select(item);
218
- item.link?.click();
219
- }
220
- break;
221
-
222
- case "*":
223
- e.preventDefault();
224
- item.expand(true);
225
- break;
226
-
227
- case "Left":
228
- case "ArrowLeft":
229
- e.preventDefault();
230
- if (item.hasChildItems && item.open) {
231
- item.collapse();
232
- } else {
233
- const parent = item.parentItem;
234
- if (parent) {
235
- parent.collapse();
236
- this[selectionManager].setActiveItem(parent);
237
- }
238
- }
239
- break;
240
-
241
- case "Right":
242
- case "ArrowRight":
243
- if (item.hasChildItems) {
244
- if (!item.open) {
245
- e.preventDefault();
246
- item.expand();
247
- } else {
248
- try {
249
- this[selectionManager].vertical = false;
250
- this[selectionManager].onKeyDown(e);
251
- } finally {
252
- this[selectionManager].vertical = true;
253
- }
254
- }
255
- } else {
256
- e.preventDefault();
257
- }
258
- break;
259
-
260
- default:
261
- this[selectionManager].onKeyDown(e);
262
- break;
263
- }
264
- }
265
-
266
- /** @private */
267
- #handleKeyUp(e: KeyboardEvent): void {
268
- const item = this[selectionManager].activeItem;
269
- if (e.defaultPrevented || !item || item.disabled) return;
270
-
271
- switch (e.key) {
272
- case "Enter":
273
- case " ":
274
- item.ripple?.hide();
275
- break;
276
- }
277
- }
278
-
279
- /** @private */
280
- #handlePointerDown(e: Event): void {
281
- if (!e.defaultPrevented && !this.#ignoreFocusVisible) {
282
- this.#ignoreFocusVisible = true;
283
- if (this[selectionManager].activeItem) {
284
- this.#updateItemFocusVisible(this[selectionManager].activeItem, true, false);
285
- }
286
- }
287
- }
288
-
289
- /** @private */
290
- #activateItem(item: M3eNavMenuItemElement): void {
291
- this.setAttribute("aria-activedescendant", item.id);
292
- scrollIntoViewIfNeeded(item, this, "instant");
293
- this.#updateFocusVisible();
294
- }
295
-
296
- /** @private */
297
- #updateFocusVisible(): void {
298
- const focused = this.matches(":focus") || this.matches(":focus-within");
299
- const focusVisible = !this.#ignoreFocusVisible && this.matches(":focus-visible");
300
- this[selectionManager].items.forEach((x) => {
301
- const active = x === this[selectionManager].activeItem;
302
- this.#updateItemFocusVisible(x, active && focused, active && focusVisible);
303
- });
304
- }
305
-
306
- /** @private */
307
- #updateItemFocusVisible(item: M3eNavMenuItemElement, focused: boolean, focusVisible: boolean): void {
308
- if (focused) {
309
- item.stateLayer?.show("focused");
310
- } else {
311
- item.stateLayer?.hide("focused");
312
- }
313
- if (focusVisible) {
314
- item.focusRing?.show();
315
- } else {
316
- item.focusRing?.hide();
317
- }
318
- }
319
- }
320
-
321
- declare global {
322
- interface HTMLElementTagNameMap {
323
- "m3e-nav-menu": M3eNavMenuElement;
324
- }
325
- }