• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

adobe / spectrum-web-components / 10697084616

04 Sep 2024 07:27AM UTC coverage: 98.105% (-0.1%) from 98.2%
10697084616

Pull #4720

github

web-flow
Merge 82d0b0314 into c76f3f54f
Pull Request #4720: fix(menu): allow menu-item to support arbitrary element as the submenu root

5158 of 5425 branches covered (95.08%)

Branch coverage included in aggregate %.

2 of 2 new or added lines in 1 file covered. (100.0%)

29 existing lines in 2 files now uncovered.

32481 of 32941 relevant lines covered (98.6%)

371.51 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.43
/packages/menu/src/Menu.ts
1
/*
36✔
2
Copyright 2020 Adobe. All rights reserved.
36✔
3
This file is licensed to you under the Apache License, Version 2.0 (the "License");
36✔
4
you may not use this file except in compliance with the License. You may obtain a copy
36✔
5
of the License at http://www.apache.org/licenses/LICENSE-2.0
36✔
6

36✔
7
Unless required by applicable law or agreed to in writing, software distributed under
36✔
8
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
36✔
9
OF ANY KIND, either express or implied. See the License for the specific language
36✔
10
governing permissions and limitations under the License.
36✔
11
*/
36✔
12

36✔
13
import {
36✔
14
    CSSResultArray,
36✔
15
    html,
36✔
16
    PropertyValues,
36✔
17
    SizedMixin,
36✔
18
    SpectrumElement,
36✔
19
    TemplateResult,
36✔
20
} from '@spectrum-web-components/base';
36✔
21
import {
36✔
22
    property,
36✔
23
    query,
36✔
24
} from '@spectrum-web-components/base/src/decorators.js';
36✔
25

36✔
26
import { MenuItem } from './MenuItem.js';
36✔
27
import type { MenuItemAddedOrUpdatedEvent } from './MenuItem.js';
36✔
28
import type { Overlay } from '@spectrum-web-components/overlay';
36✔
29
import menuStyles from './menu.css.js';
36✔
30

36✔
31
export interface MenuChildItem {
36✔
32
    menuItem: MenuItem;
36✔
33
    managed: boolean;
36✔
34
    active: boolean;
36✔
35
    focusable: boolean;
36✔
36
    focusRoot: Menu;
36✔
37
}
36✔
38

36✔
39
type SelectsType = 'none' | 'ignore' | 'inherit' | 'multiple' | 'single';
36✔
40
type RoleType = 'group' | 'menu' | 'listbox' | 'none';
36✔
41

36✔
42
function elementIsOrContains(
188✔
43
    el: Node,
188✔
44
    isOrContains: Node | undefined | null
188✔
45
): boolean {
188✔
46
    return !!isOrContains && (el === isOrContains || el.contains(isOrContains));
188✔
47
}
188✔
48

36✔
49
/**
36✔
50
 * Spectrum Menu Component
36✔
51
 * @element sp-menu
36✔
52
 *
36✔
53
 * @slot - menu items to be listed in the menu
36✔
54
 * @fires change - Announces that the `value` of the element has changed
36✔
55
 * @attr selects - whether the element has a specific selection algorithm that it applies
36✔
56
 *   to its item descendants. `single` allows only one descendent to be selected at a time.
36✔
57
 *   `multiple` allows many descendants to be selected. `inherit` will be applied dynamically
36✔
58
 *   when an ancestor of this element is actively managing the selection of its descendents.
36✔
59
 *   When the `selects` attribute is not present a `value` will not be maintained and the Menu
36✔
60
 *   Item children of this Menu will not have their `selected` state managed.
36✔
61
 */
36✔
62
export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
36✔
63
    public static override get styles(): CSSResultArray {
36✔
64
        return [menuStyles];
36✔
65
    }
36✔
66

36✔
67
    private get isSubmenu(): boolean {
36✔
UNCOV
68
        return this.slot === 'submenu';
×
UNCOV
69
    }
×
70

36✔
71
    @property({ type: String, reflect: true })
36✔
72
    public label = '';
36✔
73

36✔
74
    @property({ type: Boolean, reflect: true })
36✔
75
    public ignore = false;
36✔
76

36✔
77
    @property({ type: String, reflect: true })
36✔
78
    public selects: undefined | 'inherit' | 'single' | 'multiple';
36✔
79

36✔
80
    @property({ type: String })
36✔
81
    public value = '';
36✔
82

36✔
83
    // For the multiple select case, we'll join the value strings together
36✔
84
    // for the value property with this separator
36✔
85
    @property({ type: String, attribute: 'value-separator' })
36✔
86
    public valueSeparator = ',';
36✔
87

36✔
88
    // TODO: which of these to keep?
36✔
89
    @property({ attribute: false })
36✔
90
    public get selected(): string[] {
36✔
91
        return this._selected;
22,789✔
92
    }
22,789✔
93

36✔
94
    public set selected(selected: string[]) {
36✔
95
        if (selected === this.selected) {
1,913!
96
            return;
×
97
        }
×
98
        const old = this.selected;
1,913✔
99
        this._selected = selected;
1,913✔
100
        this.selectedItems = [];
1,913✔
101
        this.selectedItemsMap.clear();
1,913✔
102
        this.childItems.forEach((item) => {
1,913✔
103
            if (this !== item.menuData.selectionRoot) {
6,106✔
104
                return;
58✔
105
            }
58✔
106
            item.selected = this.selected.includes(item.value);
6,048✔
107
            if (item.selected) {
6,058✔
108
                this.selectedItems.push(item);
288✔
109
                this.selectedItemsMap.set(item, true);
288✔
110
            }
288✔
111
        });
1,913✔
112
        this.requestUpdate('selected', old);
1,913✔
113
    }
1,913✔
114

36✔
115
    protected _selected = [] as string[];
36✔
116

36✔
117
    @property({ attribute: false })
36✔
118
    public selectedItems = [] as MenuItem[];
36✔
119

36✔
120
    @query('slot:not([name])')
36✔
121
    public menuSlot!: HTMLSlotElement;
36✔
122

36✔
123
    private childItemSet = new Set<MenuItem>();
36✔
124
    public focusedItemIndex = 0;
36✔
125
    public focusInItemIndex = 0;
36✔
126

36✔
127
    private selectedItemsMap = new Map<MenuItem, boolean>();
36✔
128

36✔
129
    public get childItems(): MenuItem[] {
36✔
130
        if (!this.cachedChildItems) {
17,422✔
131
            this.cachedChildItems = this.updateCachedMenuItems();
1,967✔
132
        }
1,967✔
133
        return this.cachedChildItems;
17,422✔
134
    }
17,422✔
135

36✔
136
    private cachedChildItems: MenuItem[] | undefined;
36✔
137

36✔
138
    private updateCachedMenuItems(): MenuItem[] {
36✔
139
        this.cachedChildItems = [];
1,967✔
140

1,967✔
141
        if (!this.menuSlot) {
1,967✔
142
            return [];
674✔
143
        }
674✔
144

1,293✔
145
        const slottedElements = this.menuSlot.assignedElements({
1,293✔
146
            flatten: true,
1,293✔
147
        }) as HTMLElement[];
1,293✔
148
        // Recursively flatten <slot> and non-<sp-menu-item> elements assigned to the menu into a single array.
1,293✔
149
        for (const [i, slottedElement] of slottedElements.entries()) {
1,966✔
150
            if (this.childItemSet.has(slottedElement as MenuItem)) {
8,199✔
151
                // Assign <sp-menu-item> members of the array that are in this.childItemSet to this.chachedChildItems.
5,312✔
152
                this.cachedChildItems.push(slottedElement as MenuItem);
5,312✔
153
                continue;
5,312✔
154
            }
5,312✔
155
            const isHTMLSlotElement = slottedElement.localName === 'slot';
2,887✔
156
            const flattenedChildren = isHTMLSlotElement
2,887✔
157
                ? (slottedElement as HTMLSlotElement).assignedElements({
113✔
158
                      flatten: true,
113✔
159
                  })
113✔
160
                : [...slottedElement.querySelectorAll(`:scope > *`)];
2,774✔
161
            slottedElements.splice(
8,199✔
162
                i,
8,199✔
163
                1,
8,199✔
164
                slottedElement,
8,199✔
165
                ...(flattenedChildren as HTMLElement[])
8,199✔
166
            );
8,199✔
167
        }
8,199✔
168

1,293✔
169
        return this.cachedChildItems;
1,293✔
170
    }
1,967✔
171

36✔
172
    /**
36✔
173
     * Hide this getter from web-component-analyzer until
36✔
174
     * https://github.com/runem/web-component-analyzer/issues/131
36✔
175
     * has been addressed.
36✔
176
     *
36✔
177
     * @private
36✔
178
     */
36✔
179
    public get childRole(): string {
36✔
180
        if (this.resolvedRole === 'listbox') {
6,524✔
181
            return 'option';
2,634✔
182
        }
2,634✔
183
        switch (this.resolvedSelects) {
3,890✔
184
            case 'single':
4,942✔
185
                return 'menuitemradio';
337✔
186
            case 'multiple':
6,524✔
187
                return 'menuitemcheckbox';
87✔
188
            default:
6,524✔
189
                return 'menuitem';
3,466✔
190
        }
6,524✔
191
    }
6,524✔
192

36✔
193
    protected get ownRole(): string {
36✔
194
        return 'menu';
82✔
195
    }
82✔
196

36✔
197
    private resolvedSelects?: SelectsType;
36✔
198
    private resolvedRole?: RoleType;
36✔
199

36✔
200
    /**
36✔
201
     * When a descendant `<sp-menu-item>` element is added or updated it will dispatch
36✔
202
     * this event to announce its presence in the DOM. During the CAPTURE phase the first
36✔
203
     * Menu based element that the event encounters will manage the focus state of the
36✔
204
     * dispatching `<sp-menu-item>` element.
36✔
205
     * @param event
36✔
206
     */
36✔
207
    private onFocusableItemAddedOrUpdated(
36✔
208
        event: MenuItemAddedOrUpdatedEvent
5,364✔
209
    ): void {
5,364✔
210
        event.menuCascade.set(this, {
5,364✔
211
            hadFocusRoot: !!event.item.menuData.focusRoot,
5,364✔
212
            ancestorWithSelects: event.currentAncestorWithSelects,
5,364✔
213
        });
5,364✔
214
        if (this.selects) {
5,364✔
215
            event.currentAncestorWithSelects = this;
2,835✔
216
        }
2,835✔
217
        event.item.menuData.focusRoot = event.item.menuData.focusRoot || this;
5,364✔
218
    }
5,364✔
219

36✔
220
    /**
36✔
221
     * When a descendant `<sp-menu-item>` element is added or updated it will dispatch
36✔
222
     * this event to announce its presence in the DOM. During the BUBBLE phase the first
36✔
223
     * Menu based element that the event encounters that does not inherit selection will
36✔
224
     * manage the selection state of the dispatching `<sp-menu-item>` element.
36✔
225
     * @param event
36✔
226
     */
36✔
227
    private onSelectableItemAddedOrUpdated(
36✔
228
        event: MenuItemAddedOrUpdatedEvent
5,331✔
229
    ): void {
5,331✔
230
        const cascadeData = event.menuCascade.get(this);
5,331✔
231
        /* c8 ignore next 1 */
36✔
232
        if (!cascadeData) return;
36✔
233

5,331✔
234
        event.item.menuData.parentMenu = event.item.menuData.parentMenu || this;
5,331✔
235
        if (cascadeData.hadFocusRoot && !this.ignore) {
5,331✔
236
            // Only have one tab stop per Menu tree
184✔
237
            this.tabIndex = -1;
184✔
238
        }
184✔
239
        this.addChildItem(event.item);
5,331✔
240

5,331✔
241
        if (this.selects === 'inherit') {
5,331✔
242
            this.resolvedSelects = 'inherit';
119✔
243
            const ignoreMenu = event.currentAncestorWithSelects?.ignore;
119!
244
            this.resolvedRole = ignoreMenu
119✔
245
                ? 'none'
78✔
246
                : ((event.currentAncestorWithSelects?.getAttribute('role') ||
41!
247
                      this.getAttribute('role') ||
×
248
                      undefined) as RoleType);
×
249
        } else if (this.selects) {
5,331✔
250
            this.resolvedRole = this.ignore
2,716!
251
                ? 'none'
×
252
                : ((this.getAttribute('role') || undefined) as RoleType);
2,716!
253
            this.resolvedSelects = this.selects;
2,716✔
254
        } else {
5,212✔
255
            this.resolvedRole = this.ignore
2,496✔
256
                ? 'none'
184✔
257
                : ((this.getAttribute('role') || undefined) as RoleType);
2,312!
258
            this.resolvedSelects =
2,496✔
259
                this.resolvedRole === 'none' ? 'ignore' : 'none';
2,496✔
260
        }
2,496✔
261

5,331✔
262
        const selects =
5,331✔
263
            this.resolvedSelects === 'single' ||
5,331✔
264
            this.resolvedSelects === 'multiple';
2,666✔
265
        event.item.menuData.cleanupSteps.push((item: MenuItem) =>
5,331✔
266
            this.removeChildItem(item)
4,500✔
267
        );
5,331✔
268
        if (
5,331✔
269
            (selects || (!this.selects && this.resolvedSelects !== 'ignore')) &&
5,331✔
270
            !event.item.menuData.selectionRoot
5,028✔
271
        ) {
5,331✔
272
            event.item.setRole(this.childRole);
4,885✔
273
            event.item.menuData.selectionRoot =
4,885✔
274
                event.item.menuData.selectionRoot || this;
4,885✔
275
            if (event.item.selected) {
4,885✔
276
                this.selectedItemsMap.set(event.item, true);
67✔
277
                this.selectedItems = [...this.selectedItems, event.item];
67✔
278
                this._selected = [...this.selected, event.item.value];
67✔
279
                this.value = this.selected.join(this.valueSeparator);
67✔
280
            }
67✔
281
        }
4,885✔
282
    }
5,331✔
283

36✔
284
    private addChildItem(item: MenuItem): void {
36✔
285
        this.childItemSet.add(item);
5,331✔
286
        this.handleItemsChanged();
5,331✔
287
    }
5,331✔
288

36✔
289
    private async removeChildItem(item: MenuItem): Promise<void> {
36✔
290
        this.childItemSet.delete(item);
4,500✔
291
        this.cachedChildItems = undefined;
4,500✔
292
        if (item.focused) {
4,500✔
293
            this.handleItemsChanged();
9✔
294
            await this.updateComplete;
9✔
295
            this.focus();
9✔
296
        }
9✔
297
    }
4,500✔
298

36✔
299
    public constructor() {
36✔
300
        super();
852✔
301

852✔
302
        this.addEventListener(
852✔
303
            'sp-menu-item-added-or-updated',
852✔
304
            this.onSelectableItemAddedOrUpdated
852✔
305
        );
852✔
306
        this.addEventListener(
852✔
307
            'sp-menu-item-added-or-updated',
852✔
308
            this.onFocusableItemAddedOrUpdated,
852✔
309
            {
852✔
310
                capture: true,
852✔
311
            }
852✔
312
        );
852✔
313

852✔
314
        this.addEventListener('click', this.handleClick);
852✔
315
        this.addEventListener('pointerup', this.handlePointerup);
852✔
316
        this.addEventListener('focusin', this.handleFocusin);
852✔
317
        this.addEventListener('blur', this.handleBlur);
852✔
318
        this.addEventListener('sp-opened', this.handleSubmenuOpened);
852✔
319
        this.addEventListener('sp-closed', this.handleSubmenuClosed);
852✔
320
    }
852✔
321

36✔
322
    public override focus({ preventScroll }: FocusOptions = {}): void {
36✔
323
        if (
277✔
324
            !this.childItems.length ||
277✔
325
            this.childItems.every((childItem) => childItem.disabled)
265✔
326
        ) {
277✔
327
            return;
14✔
328
        }
14✔
329
        if (
263✔
330
            this.childItems.some(
263✔
331
                (childItem) => childItem.menuData.focusRoot !== this
263✔
332
            )
263✔
333
        ) {
270✔
334
            super.focus({ preventScroll });
100✔
335
            return;
100✔
336
        }
100✔
337
        this.focusMenuItemByOffset(0);
163✔
338
        super.focus({ preventScroll });
163✔
339
        const selectedItem = this.selectedItems[0];
163✔
340
        if (selectedItem && !preventScroll) {
277✔
341
            selectedItem.scrollIntoView({ block: 'nearest' });
54✔
342
        }
54✔
343
    }
277✔
344

36✔
345
    // if the click and pointerup events are on the same target, we should not
36✔
346
    // handle the click event.
36✔
347
    private pointerUpTarget = null as EventTarget | null;
36✔
348

36✔
349
    private handleClick(event: Event): void {
36✔
350
        if (this.pointerUpTarget === event.target) {
278✔
351
            this.pointerUpTarget = null;
23✔
352
            return;
23✔
353
        }
23✔
354
        this.handlePointerBasedSelection(event);
255✔
355
    }
278✔
356

36✔
357
    private handlePointerup(event: Event): void {
36✔
358
        this.pointerUpTarget = event.target;
29✔
359
        this.handlePointerBasedSelection(event);
29✔
360
    }
29✔
361

36✔
362
    private handlePointerBasedSelection(event: Event): void {
36✔
363
        // Only handle left clicks
284✔
364
        if (event instanceof MouseEvent && event.button !== 0) {
284✔
365
            return;
2✔
366
        }
2✔
367

282✔
368
        const path = event.composedPath();
282✔
369
        const target = path.find((el) => {
282✔
370
            /* c8 ignore next 3 */
36✔
371
            if (!(el instanceof Element)) {
36✔
372
                return false;
36✔
373
            }
36✔
374
            return el.getAttribute('role') === this.childRole;
1,639✔
375
        }) as MenuItem;
282✔
376
        if (event.defaultPrevented) {
284✔
377
            const index = this.childItems.indexOf(target);
50✔
378
            if (target?.menuData?.focusRoot === this && index > -1) {
50✔
379
                this.focusedItemIndex = index;
5✔
380
            }
5✔
381
            return;
50✔
382
        }
50✔
383
        if (target?.href && target.href.length) {
284✔
384
            // This event will NOT ALLOW CANCELATION as link action
3✔
385
            // cancelation should occur on the `<sp-menu-item>` itself.
3✔
386
            this.dispatchEvent(
3✔
387
                new Event('change', {
3✔
388
                    bubbles: true,
3✔
389
                    composed: true,
3✔
390
                })
3✔
391
            );
3✔
392
            return;
3✔
393
        } else if (
3✔
394
            target?.menuData?.selectionRoot === this &&
229✔
395
            this.childItems.length
154✔
396
        ) {
229✔
397
            event.preventDefault();
154✔
398
            if (target.hasSubmenu || target.open) {
154✔
399
                return;
1✔
400
            }
1✔
401
            this.selectOrToggleItem(target);
153✔
402
        } else {
229✔
403
            return;
75✔
404
        }
75✔
405
        this.prepareToCleanUp();
153✔
406
    }
284✔
407

36✔
408
    public handleFocusin(event: FocusEvent): void {
36✔
409
        if (
250✔
410
            this.childItems.some(
250✔
411
                (childItem) => childItem.menuData.focusRoot !== this
250✔
412
            )
250✔
413
        ) {
250✔
414
            return;
44✔
415
        }
44✔
416
        const activeElement = (this.getRootNode() as Document).activeElement as
206✔
417
            | MenuItem
206✔
418
            | Menu;
206✔
419
        const selectionRoot =
206✔
420
            this.childItems[this.focusedItemIndex]?.menuData.selectionRoot ||
250✔
421
            this;
1✔
422
        if (activeElement !== selectionRoot || event.target !== this) {
250✔
423
            selectionRoot.focus({ preventScroll: true });
58✔
424
            if (activeElement && this.focusedItemIndex === 0) {
58✔
425
                const offset = this.childItems.findIndex(
41✔
426
                    (childItem) => childItem === activeElement
41✔
427
                );
41✔
428
                this.focusMenuItemByOffset(Math.max(offset, 0));
41✔
429
            }
41✔
430
        }
58✔
431
        this.startListeningToKeyboard();
206✔
432
    }
250✔
433

36✔
434
    public startListeningToKeyboard(): void {
36✔
435
        this.addEventListener('keydown', this.handleKeydown);
207✔
436
    }
207✔
437

36✔
438
    public handleBlur(event: FocusEvent): void {
36✔
439
        if (elementIsOrContains(this, event.relatedTarget as Node)) {
188✔
440
            return;
10✔
441
        }
10✔
442
        this.stopListeningToKeyboard();
178✔
443
        this.childItems.forEach((child) => (child.focused = false));
178✔
444
        this.removeAttribute('aria-activedescendant');
178✔
445
    }
188✔
446

36✔
447
    public stopListeningToKeyboard(): void {
36✔
448
        this.removeEventListener('keydown', this.handleKeydown);
178✔
449
    }
178✔
450

36✔
451
    private descendentOverlays = new Map<Overlay, Overlay>();
36✔
452

36✔
453
    protected handleDescendentOverlayOpened(event: Event): void {
36✔
454
        const target = event.composedPath()[0] as MenuItem;
2✔
455
        /* c8 ignore next 1 */
36✔
456
        if (!target.overlayElement) return;
36✔
457
        this.descendentOverlays.set(
2✔
458
            target.overlayElement,
2✔
459
            target.overlayElement
2✔
460
        );
2✔
461
    }
2✔
462

36✔
463
    protected handleDescendentOverlayClosed(event: Event): void {
36✔
464
        const target = event.composedPath()[0] as MenuItem;
5✔
465
        /* c8 ignore next 1 */
36✔
466
        if (!target.overlayElement) return;
36✔
467
        this.descendentOverlays.delete(target.overlayElement);
5✔
468
    }
5✔
469

36✔
470
    public handleSubmenuClosed = (event: Event): void => {
36✔
471
        event.stopPropagation();
13✔
472
        const target = event.composedPath()[0] as Overlay;
13✔
473
        target.dispatchEvent(
13✔
474
            new Event('sp-menu-submenu-closed', {
13✔
475
                bubbles: true,
13✔
476
                composed: true,
13✔
477
            })
13✔
478
        );
13✔
479
    };
13✔
480

36✔
481
    public handleSubmenuOpened = (event: Event): void => {
36✔
482
        event.stopPropagation();
10✔
483
        const target = event.composedPath()[0] as Overlay;
10✔
484
        target.dispatchEvent(
10✔
485
            new Event('sp-menu-submenu-opened', {
10✔
486
                bubbles: true,
10✔
487
                composed: true,
10✔
488
            })
10✔
489
        );
10✔
490
        const focusedItem = this.childItems[this.focusedItemIndex];
10✔
491
        if (focusedItem) {
10✔
492
            focusedItem.focused = false;
10✔
493
        }
10✔
494
        const openedItem = event
10✔
495
            .composedPath()
10✔
496
            .find((el) => this.childItemSet.has(el as MenuItem));
10✔
497
        /* c8 ignore next 1 */
36✔
498
        if (!openedItem) return;
36✔
499
        const openedItemIndex = this.childItems.indexOf(openedItem as MenuItem);
10✔
500
        this.focusedItemIndex = openedItemIndex;
10✔
501
        this.focusInItemIndex = openedItemIndex;
10✔
502
    };
10✔
503

36✔
504
    public async selectOrToggleItem(targetItem: MenuItem): Promise<void> {
36✔
505
        const resolvedSelects = this.resolvedSelects;
154✔
506
        const oldSelectedItemsMap = new Map(this.selectedItemsMap);
154✔
507
        const oldSelected = this.selected.slice();
154✔
508
        const oldSelectedItems = this.selectedItems.slice();
154✔
509
        const oldValue = this.value;
154✔
510
        const focusedChild = this.childItems[this.focusedItemIndex];
154✔
511
        if (focusedChild) {
154✔
512
            focusedChild.focused = false;
154✔
513
            focusedChild.active = false;
154✔
514
        }
154✔
515
        this.focusedItemIndex = this.childItems.indexOf(targetItem);
154✔
516
        this.forwardFocusVisibleToItem(targetItem);
154✔
517

154✔
518
        if (resolvedSelects === 'multiple') {
154✔
519
            if (this.selectedItemsMap.has(targetItem)) {
40✔
520
                this.selectedItemsMap.delete(targetItem);
19✔
521
            } else {
24✔
522
                this.selectedItemsMap.set(targetItem, true);
21✔
523
            }
21✔
524

40✔
525
            // Match HTML select and set the first selected
40✔
526
            // item as the value. Also set the selected array
40✔
527
            // in the order of the menu items.
40✔
528
            const selected: string[] = [];
40✔
529
            const selectedItems: MenuItem[] = [];
40✔
530

40✔
531
            this.childItemSet.forEach((childItem) => {
40✔
532
                if (childItem.menuData.selectionRoot !== this) return;
117!
533

117✔
534
                if (this.selectedItemsMap.has(childItem)) {
117✔
535
                    selected.push(childItem.value);
55✔
536
                    selectedItems.push(childItem);
55✔
537
                }
55✔
538
            });
40✔
539
            this._selected = selected;
40✔
540
            this.selectedItems = selectedItems;
40✔
541
            this.value = this.selected.join(this.valueSeparator);
40✔
542
        } else {
154✔
543
            this.selectedItemsMap.clear();
114✔
544
            this.selectedItemsMap.set(targetItem, true);
114✔
545
            this.value = targetItem.value;
114✔
546
            this._selected = [targetItem.value];
114✔
547
            this.selectedItems = [targetItem];
114✔
548
        }
114✔
549

154✔
550
        const applyDefault = this.dispatchEvent(
154✔
551
            new Event('change', {
154✔
552
                cancelable: true,
154✔
553
                bubbles: true,
154✔
554
                composed: true,
154✔
555
            })
154✔
556
        );
154✔
557
        if (!applyDefault) {
154✔
558
            // Cancel the event & don't apply the selection
9✔
559
            this._selected = oldSelected;
9✔
560
            this.selectedItems = oldSelectedItems;
9✔
561
            this.selectedItemsMap = oldSelectedItemsMap;
9✔
562
            this.value = oldValue;
9✔
563
            return;
9✔
564
        }
9✔
565
        // Apply the selection changes to the menu items
145✔
566
        if (resolvedSelects === 'single') {
150✔
567
            for (const oldItem of oldSelectedItemsMap.keys()) {
79✔
568
                if (oldItem !== targetItem) {
40✔
569
                    oldItem.selected = false;
32✔
570
                }
32✔
571
            }
40✔
572
            targetItem.selected = true;
79✔
573
        } else if (resolvedSelects === 'multiple') {
147✔
574
            targetItem.selected = !targetItem.selected;
36✔
575
        }
36✔
576
    }
154✔
577

36✔
578
    protected navigateWithinMenu(event: KeyboardEvent): void {
36✔
579
        const { key } = event;
45✔
580
        const lastFocusedItem = this.childItems[this.focusedItemIndex];
45✔
581
        const direction = key === 'ArrowDown' ? 1 : -1;
45✔
582
        const itemToFocus = this.focusMenuItemByOffset(direction);
45✔
583
        if (itemToFocus === lastFocusedItem) {
45✔
584
            return;
2✔
585
        }
2✔
586
        event.preventDefault();
43✔
587
        event.stopPropagation();
43✔
588
        itemToFocus.scrollIntoView({ block: 'nearest' });
43✔
589
    }
45✔
590

36✔
591
    protected navigateBetweenRelatedMenus(event: KeyboardEvent): void {
36✔
592
        const { key } = event;
12✔
593
        event.stopPropagation();
12✔
594
        const shouldOpenSubmenu =
12✔
595
            (this.isLTR && key === 'ArrowRight') ||
12✔
596
            (!this.isLTR && key === 'ArrowLeft');
12!
597
        const shouldCloseSelfAsSubmenu =
12✔
598
            (this.isLTR && key === 'ArrowLeft') ||
12✔
599
            (!this.isLTR && key === 'ArrowRight');
12!
600
        if (shouldOpenSubmenu) {
12!
UNCOV
601
            const lastFocusedItem = this.childItems[this.focusedItemIndex];
×
UNCOV
602
            if (lastFocusedItem?.hasSubmenu) {
×
UNCOV
603
                // Remove focus while opening overlay from keyboard or the visible focus
×
UNCOV
604
                // will slip back to the first item in the menu.
×
UNCOV
605
                lastFocusedItem.openOverlay();
×
UNCOV
606
            }
×
607
        } else if (shouldCloseSelfAsSubmenu && this.isSubmenu) {
12!
UNCOV
608
            this.dispatchEvent(new Event('close', { bubbles: true }));
×
UNCOV
609
            this.updateSelectedItemIndex();
×
UNCOV
610
        }
×
611
    }
12✔
612

36✔
613
    public handleKeydown(event: KeyboardEvent): void {
36✔
614
        if (event.defaultPrevented) {
84!
UNCOV
615
            return;
×
UNCOV
616
        }
×
617
        const lastFocusedItem = this.childItems[this.focusedItemIndex];
84✔
618
        if (lastFocusedItem) {
84✔
619
            lastFocusedItem.focused = true;
84✔
620
        }
84✔
621
        const { key } = event;
84✔
622
        if (
84✔
623
            event.shiftKey &&
84!
624
            event.target !== this &&
×
625
            this.hasAttribute('tabindex')
×
626
        ) {
84!
627
            this.removeAttribute('tabindex');
×
628
            const replaceTabindex = (
×
629
                event: FocusEvent | KeyboardEvent
×
630
            ): void => {
×
631
                if (
×
632
                    !(event as KeyboardEvent).shiftKey &&
×
633
                    !this.hasAttribute('tabindex')
×
634
                ) {
×
635
                    this.tabIndex = 0;
×
636
                    document.removeEventListener('keyup', replaceTabindex);
×
637
                    this.removeEventListener('focusout', replaceTabindex);
×
638
                }
×
639
            };
×
640
            document.addEventListener('keyup', replaceTabindex);
×
641
            this.addEventListener('focusout', replaceTabindex);
×
642
        }
×
643
        if (key === 'Tab') {
84✔
644
            this.prepareToCleanUp();
5✔
645
            return;
5✔
646
        }
5✔
647
        if (key === ' ') {
84✔
648
            if (lastFocusedItem?.hasSubmenu) {
10!
649
                // Remove focus while opening overlay from keyboard or the visible focus
×
650
                // will slip back to the first item in the menu.
×
651
                // this.blur();
×
652
                lastFocusedItem.openOverlay();
×
653
                return;
×
654
            }
×
655
        }
10✔
656
        if (key === ' ' || key === 'Enter') {
84✔
657
            const childItem = this.childItems[this.focusedItemIndex];
19✔
658
            if (
19✔
659
                childItem &&
19✔
660
                childItem.menuData.selectionRoot === event.target
19✔
661
            ) {
19✔
662
                event.preventDefault();
19✔
663
                childItem.click();
19✔
664
            }
19✔
665
            return;
19✔
666
        }
19✔
667
        if (key === 'ArrowDown' || key === 'ArrowUp') {
84✔
668
            const childItem = this.childItems[this.focusedItemIndex];
48✔
669
            if (
48✔
670
                childItem &&
48✔
671
                childItem.menuData.selectionRoot === event.target
48✔
672
            ) {
48✔
673
                this.navigateWithinMenu(event);
45✔
674
            }
45✔
675
            return;
48✔
676
        }
48✔
677
        this.navigateBetweenRelatedMenus(event);
12✔
678
    }
84✔
679

36✔
680
    public focusMenuItemByOffset(offset: number): MenuItem {
36✔
681
        const step = offset || 1;
249✔
682
        const focusedItem = this.childItems[this.focusedItemIndex];
249✔
683
        if (focusedItem) {
249✔
684
            focusedItem.focused = false;
247✔
685
            // Remain active while a submenu is opened.
247✔
686
            focusedItem.active = focusedItem.open;
247✔
687
        }
247✔
688
        this.focusedItemIndex =
249✔
689
            (this.childItems.length + this.focusedItemIndex + offset) %
249✔
690
            this.childItems.length;
249✔
691
        let itemToFocus = this.childItems[this.focusedItemIndex];
249✔
692
        let availableItems = this.childItems.length;
249✔
693
        // cycle through the available items in the directions of the offset to find the next non-disabled item
249✔
694
        while (itemToFocus?.disabled && availableItems) {
249✔
695
            availableItems -= 1;
1✔
696
            this.focusedItemIndex =
1✔
697
                (this.childItems.length + this.focusedItemIndex + step) %
1✔
698
                this.childItems.length;
1✔
699
            itemToFocus = this.childItems[this.focusedItemIndex];
1✔
700
        }
1✔
701
        // if there are no non-disabled items, skip the work to focus a child
249✔
702
        if (!itemToFocus?.disabled) {
249✔
703
            this.forwardFocusVisibleToItem(itemToFocus);
249✔
704
        }
249✔
705
        return itemToFocus;
249✔
706
    }
249✔
707

36✔
708
    private prepareToCleanUp(): void {
36✔
709
        document.addEventListener(
158✔
710
            'focusout',
158✔
711
            () => {
158✔
712
                requestAnimationFrame(() => {
148✔
713
                    const focusedItem = this.childItems[this.focusedItemIndex];
147✔
714
                    if (focusedItem) {
147✔
715
                        focusedItem.focused = false;
15✔
716
                        this.updateSelectedItemIndex();
15✔
717
                    }
15✔
718
                });
148✔
719
            },
148✔
720
            { once: true }
158✔
721
        );
158✔
722
    }
158✔
723

36✔
724
    private _hasUpdatedSelectedItemIndex = false;
36✔
725

36✔
726
    public updateSelectedItemIndex(): void {
36✔
727
        let firstOrFirstSelectedIndex = 0;
1,004✔
728
        const selectedItemsMap = new Map<MenuItem, boolean>();
1,004✔
729
        const selected: string[] = [];
1,004✔
730
        const selectedItems: MenuItem[] = [];
1,004✔
731
        let itemIndex = this.childItems.length;
1,004✔
732
        while (itemIndex) {
1,004✔
733
            itemIndex -= 1;
5,935✔
734
            const childItem = this.childItems[itemIndex];
5,935✔
735
            if (childItem.menuData.selectionRoot === this) {
5,935✔
736
                if (
5,504✔
737
                    childItem.selected ||
5,504✔
738
                    (!this._hasUpdatedSelectedItemIndex &&
5,363✔
739
                        this.selected.includes(childItem.value))
5,363✔
740
                ) {
5,504✔
741
                    firstOrFirstSelectedIndex = itemIndex;
149✔
742
                    selectedItemsMap.set(childItem, true);
149✔
743
                    selected.unshift(childItem.value);
149✔
744
                    selectedItems.unshift(childItem);
149✔
745
                }
149✔
746
                // Remove "focused" from non-"selected" items ONLY
5,504✔
747
                // Preserve "focused" on index===0 when no selection
5,504✔
748
                if (itemIndex !== firstOrFirstSelectedIndex) {
5,504✔
749
                    childItem.focused = false;
4,697✔
750
                }
4,697✔
751
            }
5,504✔
752
        }
5,935✔
753
        selectedItems.map((item, i) => {
1,004✔
754
            // When there is more than one "selected" item,
149✔
755
            // ensure only the first one can be "focused"
149✔
756
            if (i > 0) {
149✔
757
                item.focused = false;
1✔
758
            }
1✔
759
        });
1,004✔
760
        this.selectedItemsMap = selectedItemsMap;
1,004✔
761
        this._selected = selected;
1,004✔
762
        this.selectedItems = selectedItems;
1,004✔
763
        this.value = this.selected.join(this.valueSeparator);
1,004✔
764
        this.focusedItemIndex = firstOrFirstSelectedIndex;
1,004✔
765
        this.focusInItemIndex = firstOrFirstSelectedIndex;
1,004✔
766
    }
1,004✔
767

36✔
768
    private _willUpdateItems = false;
36✔
769

36✔
770
    private handleItemsChanged(): void {
36✔
771
        this.cachedChildItems = undefined;
5,340✔
772
        if (!this._willUpdateItems) {
5,340✔
773
            this._willUpdateItems = true;
813✔
774
            this.cacheUpdated = this.updateCache();
813✔
775
        }
813✔
776
    }
5,340✔
777

36✔
778
    private async updateCache(): Promise<void> {
36✔
779
        if (!this.hasUpdated) {
813!
780
            await Promise.all([
×
781
                new Promise((res) => requestAnimationFrame(() => res(true))),
×
782
                this.updateComplete,
×
783
            ]);
×
784
        } else {
813✔
785
            await new Promise((res) => requestAnimationFrame(() => res(true)));
813✔
786
        }
813✔
787
        if (this.cachedChildItems === undefined) {
813✔
788
            this.updateSelectedItemIndex();
768✔
789
            this.updateItemFocus();
768✔
790
        }
768✔
791
        this._willUpdateItems = false;
813✔
792
    }
813✔
793

36✔
794
    private updateItemFocus(): void {
36✔
795
        if (this.childItems.length == 0) {
1,612✔
796
            return;
304✔
797
        }
304✔
798
        const focusInItem = this.childItems[this.focusInItemIndex];
1,308✔
799
        if (
1,308✔
800
            (this.getRootNode() as Document).activeElement ===
1,308✔
801
            focusInItem.menuData.focusRoot
1,308✔
802
        ) {
1,601✔
803
            this.forwardFocusVisibleToItem(focusInItem);
2✔
804
        }
2✔
805
    }
1,612✔
806

36✔
807
    public closeDescendentOverlays(): void {
36✔
808
        this.descendentOverlays.forEach((overlay) => {
519✔
UNCOV
809
            overlay.open = false;
×
810
        });
519✔
811
        this.descendentOverlays = new Map<Overlay, Overlay>();
519✔
812
    }
519✔
813

36✔
814
    private forwardFocusVisibleToItem(item: MenuItem): void {
36✔
815
        if (!item || item.menuData.focusRoot !== this) {
405✔
816
            return;
50✔
817
        }
50✔
818
        this.closeDescendentOverlays();
355✔
819
        const focused =
355✔
820
            this.hasVisibleFocusInTree() ||
355✔
821
            !!this.childItems.find((child) => {
226✔
822
                return child.hasVisibleFocusInTree();
1,001✔
823
            });
226✔
824
        item.focused = focused;
405✔
825
        this.setAttribute('aria-activedescendant', item.id);
405✔
826
        if (
405✔
827
            item.menuData.selectionRoot &&
405✔
828
            item.menuData.selectionRoot !== this
355✔
829
        ) {
405✔
830
            item.menuData.selectionRoot.focus();
54✔
831
        }
54✔
832
    }
405✔
833

36✔
834
    private handleSlotchange({
36✔
835
        target,
1,039✔
836
    }: Event & { target: HTMLSlotElement }): void {
1,039✔
837
        const assignedElements = target.assignedElements({
1,039✔
838
            flatten: true,
1,039✔
839
        }) as MenuItem[];
1,039✔
840
        if (this.childItems.length !== assignedElements.length) {
1,039✔
841
            assignedElements.forEach((item) => {
829✔
842
                if (typeof item.triggerUpdate !== 'undefined') {
5,769✔
843
                    item.triggerUpdate();
5,320✔
844
                } else if (
5,320✔
845
                    typeof (item as unknown as Menu).childItems !== 'undefined'
449✔
846
                ) {
449✔
847
                    (item as unknown as Menu).childItems.forEach((child) => {
74✔
848
                        child.triggerUpdate();
20✔
849
                    });
74✔
850
                }
74✔
851
            });
829✔
852
        }
829✔
853
    }
1,039✔
854

36✔
855
    protected renderMenuItemSlot(): TemplateResult {
36✔
856
        return html`
4,031✔
857
            <slot
4,031✔
858
                @sp-menu-submenu-opened=${this.handleDescendentOverlayOpened}
4,031✔
859
                @sp-menu-submenu-closed=${this.handleDescendentOverlayClosed}
4,031✔
860
                @slotchange=${this.handleSlotchange}
4,031✔
861
            ></slot>
4,031✔
862
        `;
4,031✔
863
    }
4,031✔
864

36✔
865
    public override render(): TemplateResult {
36✔
866
        return this.renderMenuItemSlot();
3,803✔
867
    }
3,803✔
868

36✔
869
    protected override firstUpdated(changed: PropertyValues): void {
36✔
870
        super.firstUpdated(changed);
838✔
871
        if (!this.hasAttribute('tabindex') && !this.ignore) {
838✔
872
            const role = this.getAttribute('role');
706✔
873
            if (role === 'group') {
706✔
874
                this.tabIndex = -1;
35✔
875
            } else {
692✔
876
                this.tabIndex = 0;
671✔
877
            }
671✔
878
        }
706✔
879
        const updates: Promise<unknown>[] = [
838✔
880
            new Promise((res) => requestAnimationFrame(() => res(true))),
838✔
881
        ];
838✔
882
        [...this.children].forEach((item) => {
838✔
883
            if ((item as MenuItem).localName === 'sp-menu-item') {
1,101✔
884
                updates.push((item as MenuItem).updateComplete);
321✔
885
            }
321✔
886
        });
838✔
887
        this.childItemsUpdated = Promise.all(updates);
838✔
888
    }
838✔
889

36✔
890
    protected override updated(changes: PropertyValues<this>): void {
36✔
891
        super.updated(changes);
4,031✔
892
        if (changes.has('selects') && this.hasUpdated) {
4,031✔
893
            this.selectsChanged();
334✔
894
        }
334✔
895
        if (
4,031✔
896
            changes.has('label') &&
4,031✔
897
            (this.label || typeof changes.get('label') !== 'undefined')
838✔
898
        ) {
4,031✔
899
            if (this.label) {
1✔
900
                this.setAttribute('aria-label', this.label);
1✔
901
                /* c8 ignore next 3 */
36✔
902
            } else {
36✔
903
                this.removeAttribute('aria-label');
36✔
904
            }
36✔
905
        }
1✔
906
    }
4,031✔
907

36✔
908
    protected selectsChanged(): void {
36✔
909
        const updates: Promise<unknown>[] = [
334✔
910
            new Promise((res) => requestAnimationFrame(() => res(true))),
334✔
911
        ];
334✔
912
        this.childItemSet.forEach((childItem) => {
334✔
913
            updates.push(childItem.triggerUpdate());
19✔
914
        });
334✔
915
        this.childItemsUpdated = Promise.all(updates);
334✔
916
    }
334✔
917

36✔
918
    public override connectedCallback(): void {
36✔
919
        super.connectedCallback();
844✔
920
        if (!this.hasAttribute('role') && !this.ignore) {
844✔
921
            this.setAttribute('role', this.ownRole);
130✔
922
        }
130✔
923
        this.updateComplete.then(() => this.updateItemFocus());
844✔
924
    }
844✔
925

36✔
926
    public override disconnectedCallback(): void {
36✔
927
        this.cachedChildItems = undefined;
844✔
928
        this.selectedItems = [];
844✔
929
        this.selectedItemsMap.clear();
844✔
930
        this.childItemSet.clear();
844✔
931
        this.descendentOverlays = new Map<Overlay, Overlay>();
844✔
932
        super.disconnectedCallback();
844✔
933
    }
844✔
934

36✔
935
    protected childItemsUpdated!: Promise<unknown[]>;
36✔
936
    protected cacheUpdated = Promise.resolve();
36✔
937
    /* c8 ignore next 3 */
36✔
938
    protected resolveCacheUpdated = (): void => {
36✔
939
        return;
36✔
940
    };
36✔
941

36✔
942
    protected override async getUpdateComplete(): Promise<boolean> {
36✔
943
        const complete = (await super.getUpdateComplete()) as boolean;
1,633✔
944
        await this.childItemsUpdated;
1,630✔
945
        await this.cacheUpdated;
1,630✔
946
        return complete;
1,630✔
947
    }
1,633✔
948
}
36✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc