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

adobe / spectrum-web-components / 13968910853

20 Mar 2025 12:07PM UTC coverage: 97.974% (-0.007%) from 97.981%
13968910853

Pull #5187

github

web-flow
Merge 940b43b8b into 72dbe629c
Pull Request #5187: fix(menu): handle pointerup and click correctly

5302 of 5608 branches covered (94.54%)

Branch coverage included in aggregate %.

38 of 40 new or added lines in 2 files covered. (95.0%)

8 existing lines in 1 file now uncovered.

33666 of 34166 relevant lines covered (98.54%)

643.69 hits per line

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

95.44
/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 {
36✔
28
    MenuItemAddedOrUpdatedEvent,
36✔
29
    MenuItemKeydownEvent,
36✔
30
} from './MenuItem.js';
36✔
31
import type { Overlay } from '@spectrum-web-components/overlay';
36✔
32
import menuStyles from './menu.css.js';
36✔
33
import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js';
36✔
34

36✔
35
export interface MenuChildItem {
36✔
36
    menuItem: MenuItem;
36✔
37
    managed: boolean;
36✔
38
    active: boolean;
36✔
39
    focusable: boolean;
36✔
40
    focusRoot: Menu;
36✔
41
}
36✔
42

36✔
43
type SelectsType = 'none' | 'ignore' | 'inherit' | 'multiple' | 'single';
36✔
44
type RoleType = 'group' | 'menu' | 'listbox' | 'none';
36✔
45

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

36✔
64
    static override shadowRootOptions = {
36✔
65
        ...SpectrumElement.shadowRootOptions,
36✔
66
        delegatesFocus: true,
36✔
67
    };
36✔
68

36✔
69
    private get isSubmenu(): boolean {
36✔
70
        return this.slot === 'submenu';
19✔
71
    }
19✔
72

36✔
73
    protected rovingTabindexController?: RovingTabindexController<MenuItem>;
36✔
74

36✔
75
    /**
36✔
76
     * label of the menu
36✔
77
     */
36✔
78
    @property({ type: String, reflect: true })
36✔
79
    public label = '';
36✔
80

36✔
81
    /**
36✔
82
     * whether menu should be ignored by roving tabindex controller
36✔
83
     */
36✔
84
    @property({ type: Boolean, reflect: true })
36✔
85
    public ignore = false;
36✔
86

36✔
87
    /**
36✔
88
     * how the menu allows selection of its items:
36✔
89
     * - `undefined` (default): no selection is allowed
36✔
90
     * - `"inherit"`: the selection behavior is managed from an ancestor
36✔
91
     * - `"single"`: only one item can be selected at a time
36✔
92
     *  - `"multiple"`: multiple items can be selected
36✔
93
     */
36✔
94
    @property({ type: String, reflect: true })
36✔
95
    public selects: undefined | 'inherit' | 'single' | 'multiple';
36✔
96

36✔
97
    /**
36✔
98
     * value of the selected item(s)
36✔
99
     */
36✔
100
    @property({ type: String })
36✔
101
    public value = '';
36✔
102

36✔
103
    // For the multiple select case, we'll join the value strings together
36✔
104
    // for the value property with this separator
36✔
105
    @property({ type: String, attribute: 'value-separator' })
36✔
106
    public valueSeparator = ',';
36✔
107

36✔
108
    /**
36✔
109
     * selected items values as string
36✔
110
     */
36✔
111
    @property({ attribute: false })
36✔
112
    public get selected(): string[] {
36✔
113
        return !this.selects ? [] : this._selected;
26,057✔
114
    }
26,057✔
115

36✔
116
    public set selected(selected: string[]) {
36✔
117
        if (selected === this.selected) {
1,853!
118
            return;
×
119
        }
×
120
        const old = this.selected;
1,853✔
121
        this._selected = selected;
1,853✔
122
        this.selectedItems = [];
1,853✔
123
        this.selectedItemsMap.clear();
1,853✔
124
        this.childItems.forEach((item) => {
1,853✔
125
            if (this !== item.menuData.selectionRoot) {
8,439✔
126
                return;
58✔
127
            }
58✔
128
            item.selected = this.selected.includes(item.value);
8,381✔
129
            if (item.selected) {
8,391✔
130
                this.selectedItems.push(item);
136✔
131
                this.selectedItemsMap.set(item, true);
136✔
132
            }
136✔
133
        });
1,853✔
134
        this.requestUpdate('selected', old);
1,853✔
135
    }
1,853✔
136

36✔
137
    protected _selected = [] as string[];
36✔
138

36✔
139
    /**
36✔
140
     * array of selected menu items
36✔
141
     */
36✔
142
    @property({ attribute: false })
36✔
143
    public selectedItems = [] as MenuItem[];
36✔
144

36✔
145
    @query('slot:not([name])')
36✔
146
    public menuSlot!: HTMLSlotElement;
36✔
147

36✔
148
    private childItemSet = new Set<MenuItem>();
36✔
149
    public focusedItemIndex = 0;
36✔
150
    public focusInItemIndex = 0;
36✔
151

36✔
152
    /**
36✔
153
     * whether or not to support pointerdown - drag - pointerup selection strategy
36✔
154
     * default is true
36✔
155
     * should be false for mobile to prevent click event being captured behind the menu-tray (cz menu immediately closes on pointerup)
36✔
156
     */
36✔
157
    public shouldSupportDragAndSelect = true;
36✔
158

36✔
159
    public get focusInItem(): MenuItem | undefined {
36✔
160
        return this.rovingTabindexController?.focusInElement;
1,706✔
161
    }
1,706✔
162

36✔
163
    protected get controlsRovingTabindex(): boolean {
36✔
164
        return true;
782✔
165
    }
782✔
166

36✔
167
    private selectedItemsMap = new Map<MenuItem, boolean>();
36✔
168

36✔
169
    /**
36✔
170
     * child items managed by menu
36✔
171
     */
36✔
172
    public get childItems(): MenuItem[] {
36✔
173
        if (!this.cachedChildItems) {
15,992✔
174
            this.cachedChildItems = this.updateCachedMenuItems();
1,909✔
175
        }
1,909✔
176
        return this.cachedChildItems;
15,992✔
177
    }
15,992✔
178

36✔
179
    private cachedChildItems: MenuItem[] | undefined;
36✔
180

36✔
181
    private updateCachedMenuItems(): MenuItem[] {
36✔
182
        if (!this.menuSlot) {
1,909✔
183
            return [];
654✔
184
        }
654✔
185
        const itemsList = [];
1,255✔
186
        const slottedElements = this.menuSlot.assignedElements({
1,255✔
187
            flatten: true,
1,255✔
188
        }) as HTMLElement[];
1,255✔
189
        // Recursively flatten <slot> and non-<sp-menu-item> elements assigned to the menu into a single array.
1,255✔
190
        for (const [i, slottedElement] of slottedElements.entries()) {
1,908✔
191
            if (this.childItemSet.has(slottedElement as MenuItem)) {
9,879✔
192
                // Assign <sp-menu-item> members of the array that are in this.childItemSet to this.chachedChildItems.
6,414✔
193
                itemsList.push(slottedElement as MenuItem);
6,414✔
194
                continue;
6,414✔
195
            }
6,414✔
196
            const isHTMLSlotElement = slottedElement.localName === 'slot';
3,465✔
197
            const flattenedChildren = isHTMLSlotElement
3,465✔
198
                ? (slottedElement as HTMLSlotElement).assignedElements({
113✔
199
                      flatten: true,
113✔
200
                  })
113✔
201
                : [...slottedElement.querySelectorAll(`:scope > *`)];
3,352✔
202
            slottedElements.splice(
9,879✔
203
                i,
9,879✔
204
                1,
9,879✔
205
                slottedElement,
9,879✔
206
                ...(flattenedChildren as HTMLElement[])
9,879✔
207
            );
9,879✔
208
        }
9,879✔
209

1,255✔
210
        this.cachedChildItems = [...itemsList];
1,255✔
211
        this.rovingTabindexController?.clearElementCache();
1,909✔
212

1,909✔
213
        return this.cachedChildItems;
1,909✔
214
    }
1,909✔
215

36✔
216
    /**
36✔
217
     * Hide this getter from web-component-analyzer until
36✔
218
     * https://github.com/runem/web-component-analyzer/issues/131
36✔
219
     * has been addressed.
36✔
220
     *
36✔
221
     * @private
36✔
222
     */
36✔
223
    public get childRole(): string {
36✔
224
        if (this.resolvedRole === 'listbox') {
7,120✔
225
            return 'option';
3,926✔
226
        }
3,926✔
227
        switch (this.resolvedSelects) {
3,194✔
228
            case 'single':
4,274✔
229
                return 'menuitemradio';
217✔
230
            case 'multiple':
7,120✔
231
                return 'menuitemcheckbox';
87✔
232
            default:
7,120✔
233
                return 'menuitem';
2,890✔
234
        }
7,120✔
235
    }
7,120✔
236

36✔
237
    protected get ownRole(): string {
36✔
238
        return 'menu';
128✔
239
    }
128✔
240

36✔
241
    /**
36✔
242
     * menuitem role based on selection type
36✔
243
     */
36✔
244
    private resolvedSelects?: SelectsType;
36✔
245

36✔
246
    /**
36✔
247
     * menu role based on selection type
36✔
248
     */
36✔
249
    private resolvedRole?: RoleType;
36✔
250

36✔
251
    /**
36✔
252
     * When a descendant `<sp-menu-item>` element is added or updated it will dispatch
36✔
253
     * this event to announce its presence in the DOM. During the CAPTURE phase the first
36✔
254
     * Menu based element that the event encounters will manage the focus state of the
36✔
255
     * dispatching `<sp-menu-item>` element.
36✔
256
     * @param event
36✔
257
     */
36✔
258
    private onFocusableItemAddedOrUpdated(
36✔
259
        event: MenuItemAddedOrUpdatedEvent
6,666✔
260
    ): void {
6,666✔
261
        event.menuCascade.set(this, {
6,666✔
262
            hadFocusRoot: !!event.item.menuData.focusRoot,
6,666✔
263
            ancestorWithSelects: event.currentAncestorWithSelects,
6,666✔
264
        });
6,666✔
265
        if (this.selects) {
6,666✔
266
            event.currentAncestorWithSelects = this;
2,772✔
267
        }
2,772✔
268
        event.item.menuData.focusRoot = event.item.menuData.focusRoot || this;
6,666✔
269
    }
6,666✔
270

36✔
271
    /**
36✔
272
     * When a descendant `<sp-menu-item>` element is added or updated it will dispatch
36✔
273
     * this event to announce its presence in the DOM. During the BUBBLE phase the first
36✔
274
     * Menu based element that the event encounters that does not inherit selection will
36✔
275
     * manage the selection state of the dispatching `<sp-menu-item>` element.
36✔
276
     * @param event
36✔
277
     */
36✔
278
    private onSelectableItemAddedOrUpdated(
36✔
279
        event: MenuItemAddedOrUpdatedEvent
6,438✔
280
    ): void {
6,438✔
281
        const cascadeData = event.menuCascade.get(this);
6,438✔
282
        /* c8 ignore next 1 */
36✔
283
        if (!cascadeData) return;
36✔
284

6,438✔
285
        event.item.menuData.parentMenu = event.item.menuData.parentMenu || this;
6,438✔
286
        this.addChildItem(event.item);
6,438✔
287

6,438✔
288
        if (this.selects === 'inherit') {
6,438✔
289
            this.resolvedSelects = 'inherit';
89✔
290
            const ignoreMenu = event.currentAncestorWithSelects?.ignore;
89!
291
            this.resolvedRole = ignoreMenu
89✔
292
                ? 'none'
48✔
293
                : ((event.currentAncestorWithSelects?.getAttribute('role') ||
41!
294
                      this.getAttribute('role') ||
×
295
                      undefined) as RoleType);
×
296
        } else if (this.selects) {
6,438✔
297
            this.resolvedRole = this.ignore
2,680!
298
                ? 'none'
×
299
                : ((this.getAttribute('role') || undefined) as RoleType);
2,680!
300
            this.resolvedSelects = this.selects;
2,680✔
301
        } else {
6,349✔
302
            this.resolvedRole = this.ignore
3,669!
303
                ? 'none'
×
304
                : ((this.getAttribute('role') || undefined) as RoleType);
3,669!
305
            this.resolvedSelects =
3,669✔
306
                this.resolvedRole === 'none' ? 'ignore' : 'none';
3,669✔
307
        }
3,669✔
308

6,438✔
309
        if (this.resolvedRole === 'none') {
6,438✔
310
            return;
72✔
311
        }
72✔
312

6,366✔
313
        const selects =
6,366✔
314
            this.resolvedSelects === 'single' ||
6,366✔
315
            this.resolvedSelects === 'multiple';
3,736✔
316
        event.item.menuData.cleanupSteps.push((item: MenuItem) =>
6,438✔
317
            this.removeChildItem(item)
5,582✔
318
        );
6,438✔
319
        if (
6,438✔
320
            (selects || (!this.selects && this.resolvedSelects !== 'ignore')) &&
6,438✔
321
            !event.item.menuData.selectionRoot
6,325✔
322
        ) {
6,438✔
323
            event.item.setRole(this.childRole);
6,186✔
324
            event.item.menuData.selectionRoot =
6,186✔
325
                event.item.menuData.selectionRoot || this;
6,186✔
326
            if (event.item.selected) {
6,186✔
327
                this.selectedItemsMap.set(event.item, true);
48✔
328
                this.selectedItems = [...this.selectedItems, event.item];
48✔
329
                this._selected = [...this.selected, event.item.value];
48✔
330
                this.value = this.selected.join(this.valueSeparator);
48✔
331
            }
48✔
332
        }
6,186✔
333
    }
6,438✔
334

36✔
335
    private addChildItem(item: MenuItem): void {
36✔
336
        this.childItemSet.add(item);
6,438✔
337
        this.handleItemsChanged();
6,438✔
338
    }
6,438✔
339

36✔
340
    private async removeChildItem(item: MenuItem): Promise<void> {
36✔
341
        if (item.focused || item.hasAttribute('focused') || item.active) {
5,582✔
342
            this._updateFocus = this.getNeighboringFocusableElement(item);
83✔
343
        }
83✔
344
        this.childItemSet.delete(item);
5,582✔
345
        this.cachedChildItems = undefined;
5,582✔
346
    }
5,582✔
347

36✔
348
    public constructor() {
36✔
349
        super();
834✔
350

834✔
351
        /**
834✔
352
         * only create an RTI if menu controls keyboard navigation and one does not already exist
834✔
353
         */
834✔
354
        if (!this.rovingTabindexController && this.controlsRovingTabindex) {
834✔
355
            this.rovingTabindexController =
782✔
356
                new RovingTabindexController<MenuItem>(this, {
782✔
357
                    direction: 'vertical',
782✔
358
                    focusInIndex: (elements: MenuItem[] | undefined) => {
782✔
359
                        let firstEnabledIndex = -1;
17,154✔
360
                        const firstSelectedIndex = elements?.findIndex(
17,154!
361
                            (el, index) => {
17,154✔
362
                                if (
1,499,568✔
363
                                    !elements[firstEnabledIndex] &&
1,499,568✔
364
                                    !el.disabled
16,836✔
365
                                ) {
1,499,568✔
366
                                    firstEnabledIndex = index;
16,784✔
367
                                }
16,784✔
368
                                return el.selected && !el.disabled;
1,499,568✔
369
                            }
1,499,568✔
370
                        );
17,154✔
371
                        return elements &&
17,154✔
372
                            firstSelectedIndex &&
17,154✔
373
                            elements[firstSelectedIndex]
16,847✔
374
                            ? firstSelectedIndex
1,194✔
375
                            : firstEnabledIndex;
15,960✔
376
                    },
17,154✔
377
                    elements: () => this.childItems,
782✔
378
                    isFocusableElement: this.isFocusableElement.bind(this),
782✔
379
                    hostDelegatesFocus: true,
782✔
380
                });
782✔
381
        }
782✔
382

834✔
383
        this.addEventListener(
834✔
384
            'sp-menu-item-added-or-updated',
834✔
385
            this.onSelectableItemAddedOrUpdated
834✔
386
        );
834✔
387
        this.addEventListener(
834✔
388
            'sp-menu-item-added-or-updated',
834✔
389
            this.onFocusableItemAddedOrUpdated,
834✔
390
            {
834✔
391
                capture: true,
834✔
392
            }
834✔
393
        );
834✔
394

834✔
395
        this.addEventListener('click', this.handleClick);
834✔
396
        this.addEventListener('mouseover', this.handleMouseover);
834✔
397
        this.addEventListener('focusout', this.handleFocusout);
834✔
398
        this.addEventListener('sp-menu-item-keydown', this.handleKeydown);
834✔
399
        this.addEventListener('pointerup', this.handlePointerup);
834✔
400
        this.addEventListener('sp-opened', this.handleSubmenuOpened);
834✔
401
        this.addEventListener('sp-closed', this.handleSubmenuClosed);
834✔
402
    }
834✔
403

36✔
404
    /**
36✔
405
     * for picker elements, will set focus on first selected item
36✔
406
     */
36✔
407
    public focusOnFirstSelectedItem({
36✔
408
        preventScroll,
98✔
409
    }: FocusOptions = {}): void {
98✔
410
        if (!this.rovingTabindexController) return;
98!
411
        const selectedItem = this.selectedItems.find((el) =>
98✔
412
            this.isFocusableElement(el)
11✔
413
        );
98✔
414
        if (!selectedItem) {
98✔
415
            this.focus({ preventScroll });
87✔
416
            return;
87✔
417
        }
87✔
418

11✔
419
        if (selectedItem && !preventScroll) {
98✔
420
            selectedItem.scrollIntoView({ block: 'nearest' });
11✔
421
        }
11✔
422
        this.rovingTabindexController?.focusOnItem(selectedItem);
98!
423
    }
98✔
424

36✔
425
    public override focus({ preventScroll }: FocusOptions = {}): void {
36✔
426
        if (this.rovingTabindexController) {
128✔
427
            if (
128✔
428
                !this.childItems.length ||
128✔
429
                this.childItems.every((childItem) => childItem.disabled)
127✔
430
            ) {
128✔
431
                return;
3✔
432
            }
3✔
433
            if (
125✔
434
                this.childItems.some(
125✔
435
                    (childItem) => childItem.menuData.focusRoot !== this
125✔
436
                )
125✔
437
            ) {
128!
438
                super.focus({ preventScroll });
×
439
                return;
×
440
            }
✔
441
            this.rovingTabindexController.focus({ preventScroll });
125✔
442
        }
125✔
443
    }
128✔
444

36✔
445
    // if the click and pointerup events are on the same target, we should not
36✔
446
    // handle the click event.
36✔
447
    private pointerUpTarget = null as EventTarget | null;
36✔
448

36✔
449
    private handleMouseover(event: MouseEvent): void {
36✔
450
        const { target } = event;
64✔
451
        const menuItem = target as MenuItem;
64✔
452
        if (
64✔
453
            this.childItems.includes(menuItem) &&
64✔
454
            this.isFocusableElement(menuItem)
49✔
455
        ) {
64✔
456
            this.rovingTabindexController?.focusOnItem(menuItem);
49✔
457
        }
49✔
458
    }
64✔
459

36✔
460
    private handleFocusout(): void {
36✔
461
        if (!this.matches(':focus-within'))
365✔
462
            this.rovingTabindexController?.reset();
365✔
463
    }
365✔
464

36✔
465
    private handleClick(event: Event): void {
36✔
466
        if (this.pointerUpTarget === event.target) {
238✔
467
            this.pointerUpTarget = null;
38✔
468
            return;
38✔
469
        }
38✔
470
        this.handlePointerBasedSelection(event);
200✔
471
    }
238✔
472

36✔
473
    private handlePointerup(event: Event): void {
36✔
474
        /*
44✔
475
         * early return if drag and select is not supported
44✔
476
         * in this case, selection will be handled by the click event
44✔
477
         */
44✔
478
        if (!this.shouldSupportDragAndSelect) {
44!
NEW
479
            return;
×
NEW
480
        }
×
481
        this.pointerUpTarget = event.target;
44✔
482
        this.handlePointerBasedSelection(event);
44✔
483
    }
44✔
484

36✔
485
    private async handlePointerBasedSelection(event: Event): Promise<void> {
36✔
486
        // Only handle left clicks
244✔
487
        if (event instanceof MouseEvent && event.button !== 0) {
244✔
488
            return;
2✔
489
        }
2✔
490

242✔
491
        const path = event.composedPath();
242✔
492
        const target = path.find((el) => {
242✔
493
            /* c8 ignore next 3 */
36✔
494
            if (!(el instanceof Element)) {
36✔
495
                return false;
36✔
496
            }
36✔
497
            return el.getAttribute('role') === this.childRole;
934✔
498
        }) as MenuItem;
242✔
499
        if (event.defaultPrevented) {
244✔
500
            const index = this.childItems.indexOf(target);
73✔
501
            if (target?.menuData?.focusRoot === this && index > -1) {
73✔
502
                this.focusedItemIndex = index;
10✔
503
            }
10✔
504
            return;
73✔
505
        }
73✔
506
        if (target?.href && target.href.length) {
244✔
507
            // This event will NOT ALLOW CANCELATION as link action
8✔
508
            // cancelation should occur on the `<sp-menu-item>` itself.
8✔
509
            await new Promise((resolve) => {
8✔
510
                this.dispatchEvent(
8✔
511
                    new Event('change', {
8✔
512
                        bubbles: true,
8✔
513
                        composed: true,
8✔
514
                    })
8✔
515
                );
8✔
516
                resolve(true);
8✔
517
            });
8✔
518
            return;
8✔
519
        } else if (
8✔
520
            target?.menuData?.selectionRoot === this &&
161✔
521
            this.childItems.length
145✔
522
        ) {
161✔
523
            event.preventDefault();
145✔
524
            if (target.hasSubmenu || target.open) {
145!
UNCOV
525
                return;
×
UNCOV
526
            }
×
527
            this.selectOrToggleItem(target);
145✔
528
        } else {
161✔
529
            return;
16✔
530
        }
16✔
531
        this.prepareToCleanUp();
145✔
532
    }
244✔
533

36✔
534
    private descendentOverlays = new Map<Overlay, Overlay>();
36✔
535

36✔
536
    protected handleDescendentOverlayOpened(event: Event): void {
36✔
537
        const target = event.composedPath()[0] as MenuItem;
46✔
538
        /* c8 ignore next 1 */
36✔
539
        if (!target.overlayElement) return;
36✔
540
        this.descendentOverlays.set(
46✔
541
            target.overlayElement,
46✔
542
            target.overlayElement
46✔
543
        );
46✔
544
    }
46✔
545

36✔
546
    protected handleDescendentOverlayClosed(event: Event): void {
36✔
547
        const target = event.composedPath()[0] as MenuItem;
47✔
548
        /* c8 ignore next 1 */
36✔
549
        if (!target.overlayElement) return;
36✔
550
        this.descendentOverlays.delete(target.overlayElement);
47✔
551
    }
47✔
552

36✔
553
    public handleSubmenuClosed = (event: Event): void => {
36✔
554
        event.stopPropagation();
43✔
555
        const target = event.composedPath()[0] as Overlay;
43✔
556
        target.dispatchEvent(
43✔
557
            new Event('sp-menu-submenu-closed', {
43✔
558
                bubbles: true,
43✔
559
                composed: true,
43✔
560
            })
43✔
561
        );
43✔
562
    };
43✔
563

36✔
564
    /**
36✔
565
     * given a menu item, returns the next focusable menu item before or after it;
36✔
566
     * if no menu item is provided, returns the first focusable menu item
36✔
567
     * @param menuItem {MenuItem}
36✔
568
     * @param before {boolean} return the item before; default is false
36✔
569
     * @returns {MenuItem}
36✔
570
     */
36✔
571
    public getNeighboringFocusableElement(
36✔
572
        menuItem?: MenuItem,
99✔
573
        before = false
99✔
574
    ): MenuItem {
99✔
575
        const diff = before ? -1 : 1;
99✔
576
        const elements = this.rovingTabindexController?.elements || [];
99✔
577
        const index = !!menuItem ? elements.indexOf(menuItem) : -1;
99✔
578
        let newIndex = Math.min(Math.max(0, index + diff), elements.length - 1);
99✔
579
        while (
99✔
580
            !this.isFocusableElement(elements[newIndex]) &&
99✔
581
            0 < newIndex &&
17✔
582
            newIndex < elements.length - 1
8✔
583
        ) {
99✔
584
            newIndex += diff;
2✔
585
        }
2✔
586
        return !!this.isFocusableElement(elements[newIndex])
99✔
587
            ? (elements[newIndex] as MenuItem)
84✔
588
            : menuItem || elements[0];
15!
589
    }
99✔
590

36✔
591
    public handleSubmenuOpened = (event: Event): void => {
36✔
592
        event.stopPropagation();
42✔
593
        const target = event.composedPath()[0] as Overlay;
42✔
594
        target.dispatchEvent(
42✔
595
            new Event('sp-menu-submenu-opened', {
42✔
596
                bubbles: true,
42✔
597
                composed: true,
42✔
598
            })
42✔
599
        );
42✔
600

42✔
601
        const openedItem = event
42✔
602
            .composedPath()
42✔
603
            .find((el) => this.childItemSet.has(el as MenuItem));
42✔
604
        /* c8 ignore next 1 */
36✔
605
        if (!openedItem) return;
36✔
606
    };
42✔
607

36✔
608
    public async selectOrToggleItem(targetItem: MenuItem): Promise<void> {
36✔
609
        const resolvedSelects = this.resolvedSelects;
196✔
610
        const oldSelectedItemsMap = new Map(this.selectedItemsMap);
196✔
611
        const oldSelected = this.selected.slice();
196✔
612
        const oldSelectedItems = this.selectedItems.slice();
196✔
613
        const oldValue = this.value;
196✔
614

196✔
615
        if (targetItem.menuData.selectionRoot !== this) {
196✔
616
            return;
13✔
617
        }
13✔
618

183✔
619
        if (resolvedSelects === 'multiple') {
194✔
620
            if (this.selectedItemsMap.has(targetItem)) {
42✔
621
                this.selectedItemsMap.delete(targetItem);
20✔
622
            } else {
26✔
623
                this.selectedItemsMap.set(targetItem, true);
22✔
624
            }
22✔
625

42✔
626
            // Match HTML select and set the first selected
42✔
627
            // item as the value. Also set the selected array
42✔
628
            // in the order of the menu items.
42✔
629
            const selected: string[] = [];
42✔
630
            const selectedItems: MenuItem[] = [];
42✔
631

42✔
632
            this.childItemSet.forEach((childItem) => {
42✔
633
                if (childItem.menuData.selectionRoot !== this) return;
123!
634

123✔
635
                if (this.selectedItemsMap.has(childItem)) {
123✔
636
                    selected.push(childItem.value);
56✔
637
                    selectedItems.push(childItem);
56✔
638
                }
56✔
639
            });
42✔
640
            this._selected = selected;
42✔
641
            this.selectedItems = selectedItems;
42✔
642
            this.value = this.selected.join(this.valueSeparator);
42✔
643
        } else {
196✔
644
            this.selectedItemsMap.clear();
141✔
645
            this.selectedItemsMap.set(targetItem, true);
141✔
646
            this.value = targetItem.value;
141✔
647
            this._selected = [targetItem.value];
141✔
648
            this.selectedItems = [targetItem];
141✔
649
        }
141✔
650

183✔
651
        const applyDefault = this.dispatchEvent(
183✔
652
            new Event('change', {
183✔
653
                cancelable: true,
183✔
654
                bubbles: true,
183✔
655
                composed: true,
183✔
656
            })
183✔
657
        );
183✔
658

183✔
659
        if (!applyDefault) {
196✔
660
            // Cancel the event & don't apply the selection
10✔
661
            this._selected = oldSelected;
10✔
662
            this.selectedItems = oldSelectedItems;
10✔
663
            this.selectedItemsMap = oldSelectedItemsMap;
10✔
664
            this.value = oldValue;
10✔
665
            return;
10✔
666
        }
10✔
667
        // Apply the selection changes to the menu items
173✔
668
        if (resolvedSelects === 'single') {
191✔
669
            for (const oldItem of oldSelectedItemsMap.keys()) {
73✔
670
                if (oldItem !== targetItem) {
32✔
671
                    oldItem.selected = false;
26✔
672
                }
26✔
673
            }
32✔
674
            targetItem.selected = true;
73✔
675
        } else if (resolvedSelects === 'multiple') {
188✔
676
            targetItem.selected = !targetItem.selected;
38✔
677
        } else if (
38✔
678
            !targetItem.hasSubmenu &&
62✔
679
            targetItem?.menuData?.focusRoot === this
34!
680
        ) {
62✔
681
            this.dispatchEvent(new Event('close', { bubbles: true }));
30✔
682
        }
30✔
683
    }
196✔
684

36✔
685
    protected navigateBetweenRelatedMenus(event: MenuItemKeydownEvent): void {
36✔
686
        const { key, root } = event;
90✔
687
        const shouldOpenSubmenu =
90✔
688
            (this.isLTR && key === 'ArrowRight') ||
90✔
689
            (!this.isLTR && key === 'ArrowLeft');
84✔
690
        const shouldCloseSelfAsSubmenu =
90✔
691
            (this.isLTR && key === 'ArrowLeft') ||
90✔
692
            (!this.isLTR && key === 'ArrowRight') ||
86✔
693
            key === 'Escape';
84✔
694
        const lastFocusedItem = root as MenuItem;
90✔
695
        if (shouldOpenSubmenu) {
90✔
696
            if (lastFocusedItem?.hasSubmenu) {
10!
697
                //open submenu and set focus
10✔
698
                event.stopPropagation();
10✔
699
                lastFocusedItem.openOverlay();
10✔
700
            }
10✔
701
        } else if (shouldCloseSelfAsSubmenu && this.isSubmenu) {
90✔
702
            event.stopPropagation();
6✔
703
            this.dispatchEvent(new Event('close', { bubbles: true }));
6✔
704
            this.updateSelectedItemIndex();
6✔
705
        }
6✔
706
    }
90✔
707

36✔
708
    public handleKeydown(event: Event): void {
36✔
709
        if (event.defaultPrevented || !this.rovingTabindexController) {
153✔
710
            return;
34✔
711
        }
34✔
712
        const { key, root, shiftKey, target } = event as MenuItemKeydownEvent;
119✔
713
        const openSubmenuKey = ['Enter', ' '].includes(key);
119✔
714
        if (shiftKey && target !== this && this.hasAttribute('tabindex')) {
153!
715
            this.removeAttribute('tabindex');
×
716
            const replaceTabindex = (
×
717
                event: FocusEvent | KeyboardEvent
×
718
            ): void => {
×
719
                if (
×
720
                    !(event as KeyboardEvent).shiftKey &&
×
721
                    !this.hasAttribute('tabindex')
×
722
                ) {
×
723
                    document.removeEventListener('keyup', replaceTabindex);
×
724
                    this.removeEventListener('focusout', replaceTabindex);
×
725
                }
×
726
            };
×
UNCOV
727
            document.addEventListener('keyup', replaceTabindex);
×
UNCOV
728
            this.addEventListener('focusout', replaceTabindex);
×
UNCOV
729
        }
✔
730
        if (key === 'Tab') {
153✔
731
            this.closeDescendentOverlays();
5✔
732
            return;
5✔
733
        }
5✔
734
        if (openSubmenuKey && root?.hasSubmenu && !root.open) {
153✔
735
            // Remove focus while opening overlay from keyboard or the visible focus
1✔
736
            // will slip back to the first item in the menu.
1✔
737
            event.preventDefault();
1✔
738
            root.openOverlay();
1✔
739
            return;
1✔
740
        }
1✔
741
        if (key === ' ' || key === 'Enter') {
153✔
742
            event.preventDefault();
23✔
743
            root?.focusElement?.click();
23!
744
            if (root) this.selectOrToggleItem(root);
23✔
745
            return;
23✔
746
        }
23✔
747
        this.navigateBetweenRelatedMenus(event as MenuItemKeydownEvent);
90✔
748
    }
153✔
749

36✔
750
    private _hasUpdatedSelectedItemIndex = false;
36✔
751

36✔
752
    /**
36✔
753
     * on focus, removes focus from focus styling item, and updates the selected item index
36✔
754
     */
36✔
755
    private prepareToCleanUp(): void {
36✔
756
        document.addEventListener(
145✔
757
            'focusout',
145✔
758
            () => {
145✔
759
                requestAnimationFrame(() => {
135✔
760
                    const focusedItem = this.focusInItem;
133✔
761
                    if (focusedItem) {
133✔
762
                        focusedItem.focused = false;
63✔
763
                    }
63✔
764
                });
135✔
765
            },
135✔
766
            { once: true }
145✔
767
        );
145✔
768
    }
145✔
769

36✔
770
    public updateSelectedItemIndex(): void {
36✔
771
        let firstOrFirstSelectedIndex = 0;
959✔
772
        const selectedItemsMap = new Map<MenuItem, boolean>();
959✔
773
        const selected: string[] = [];
959✔
774
        const selectedItems: MenuItem[] = [];
959✔
775
        let itemIndex = this.childItems.length;
959✔
776
        while (itemIndex) {
959✔
777
            itemIndex -= 1;
7,005✔
778
            const childItem = this.childItems[itemIndex];
7,005✔
779
            if (childItem.menuData.selectionRoot === this) {
7,005✔
780
                if (
6,773✔
781
                    childItem.selected ||
6,773✔
782
                    (!this._hasUpdatedSelectedItemIndex &&
6,687✔
783
                        this.selected.includes(childItem.value))
6,687✔
784
                ) {
6,773✔
785
                    firstOrFirstSelectedIndex = itemIndex;
94✔
786
                    selectedItemsMap.set(childItem, true);
94✔
787
                    selected.unshift(childItem.value);
94✔
788
                    selectedItems.unshift(childItem);
94✔
789
                }
94✔
790
                // Remove "focused" from non-"selected" items ONLY
6,773✔
791
                // Preserve "focused" on index===0 when no selection
6,773✔
792
                if (itemIndex !== firstOrFirstSelectedIndex) {
6,773✔
793
                    childItem.focused = false;
5,955✔
794
                }
5,955✔
795
            }
6,773✔
796
        }
7,005✔
797

959✔
798
        this.selectedItemsMap = selectedItemsMap;
959✔
799
        this._selected = selected;
959✔
800
        this.selectedItems = selectedItems;
959✔
801
        this.value = this.selected.join(this.valueSeparator);
959✔
802
        this.focusedItemIndex = firstOrFirstSelectedIndex;
959✔
803
        this.focusInItemIndex = firstOrFirstSelectedIndex;
959✔
804
    }
959✔
805

36✔
806
    private _willUpdateItems = false;
36✔
807
    private _updateFocus?: MenuItem;
36✔
808

36✔
809
    private handleItemsChanged(): void {
36✔
810
        this.cachedChildItems = undefined;
6,438✔
811
        if (!this._willUpdateItems) {
6,438✔
812
            this._willUpdateItems = true;
768✔
813
            this.cacheUpdated = this.updateCache();
768✔
814
        }
768✔
815
    }
6,438✔
816

36✔
817
    private async updateCache(): Promise<void> {
36✔
818
        if (!this.hasUpdated) {
768!
819
            await Promise.all([
×
UNCOV
820
                new Promise((res) => requestAnimationFrame(() => res(true))),
×
UNCOV
821
                this.updateComplete,
×
UNCOV
822
            ]);
×
823
        } else {
768✔
824
            await new Promise((res) => requestAnimationFrame(() => res(true)));
768✔
825
        }
768✔
826
        if (this.cachedChildItems === undefined) {
768✔
827
            this.updateSelectedItemIndex();
747✔
828
            this.updateItemFocus();
747✔
829
        }
747✔
830

768✔
831
        this._willUpdateItems = false;
768✔
832
    }
768✔
833

36✔
834
    private updateItemFocus(): void {
36✔
835
        this.focusInItem?.setAttribute('tabindex', '0');
1,573✔
836
        if (this.childItems.length == 0) {
1,573✔
837
            return;
324✔
838
        }
324✔
839
    }
1,573✔
840

36✔
841
    public closeDescendentOverlays(): void {
36✔
842
        this.descendentOverlays.forEach((overlay) => {
216✔
843
            overlay.open = false;
11✔
844
        });
216✔
845
        this.descendentOverlays = new Map<Overlay, Overlay>();
216✔
846
    }
216✔
847

36✔
848
    private handleSlotchange({
36✔
849
        target,
1,043✔
850
    }: Event & { target: HTMLSlotElement }): void {
1,043✔
851
        const assignedElements = target.assignedElements({
1,043✔
852
            flatten: true,
1,043✔
853
        }) as MenuItem[];
1,043✔
854
        if (this.childItems.length !== assignedElements.length) {
1,043✔
855
            assignedElements.forEach((item) => {
807✔
856
                if (typeof item.triggerUpdate !== 'undefined') {
6,963✔
857
                    item.triggerUpdate();
6,529✔
858
                } else if (
6,529✔
859
                    typeof (item as unknown as Menu).childItems !== 'undefined'
434✔
860
                ) {
434✔
861
                    (item as unknown as Menu).childItems.forEach((child) => {
69✔
862
                        child.triggerUpdate();
28✔
863
                    });
69✔
864
                }
69✔
865
            });
807✔
866
        }
807✔
867
        if (!!this._updateFocus) {
1,043✔
868
            this.rovingTabindexController?.focusOnItem(this._updateFocus);
3!
869
            this._updateFocus = undefined;
3✔
870
        }
3✔
871
    }
1,043✔
872

36✔
873
    protected renderMenuItemSlot(): TemplateResult {
36✔
874
        return html`
3,890✔
875
            <slot
3,890✔
876
                @sp-menu-submenu-opened=${this.handleDescendentOverlayOpened}
3,890✔
877
                @sp-menu-submenu-closed=${this.handleDescendentOverlayClosed}
3,890✔
878
                @slotchange=${this.handleSlotchange}
3,890✔
879
            ></slot>
3,890✔
880
        `;
3,890✔
881
    }
3,890✔
882

36✔
883
    public override render(): TemplateResult {
36✔
884
        return this.renderMenuItemSlot();
3,649✔
885
    }
3,649✔
886

36✔
887
    protected override firstUpdated(changed: PropertyValues): void {
36✔
888
        super.firstUpdated(changed);
820✔
889
        const updates: Promise<unknown>[] = [
820✔
890
            new Promise((res) => requestAnimationFrame(() => res(true))),
820✔
891
        ];
820✔
892
        [...this.children].forEach((item) => {
820✔
893
            if ((item as MenuItem).localName === 'sp-menu-item') {
1,138✔
894
                updates.push((item as MenuItem).updateComplete);
422✔
895
            }
422✔
896
        });
820✔
897
        this.childItemsUpdated = Promise.all(updates);
820✔
898
    }
820✔
899

36✔
900
    protected override updated(changes: PropertyValues<this>): void {
36✔
901
        super.updated(changes);
3,890✔
902
        if (changes.has('selects') && this.hasUpdated) {
3,890✔
903
            this.selectsChanged();
303✔
904
        }
303✔
905
        if (
3,890✔
906
            changes.has('label') &&
3,890✔
907
            (this.label || typeof changes.get('label') !== 'undefined')
820✔
908
        ) {
3,890✔
909
            if (this.label) {
1✔
910
                this.setAttribute('aria-label', this.label);
1✔
911
                /* c8 ignore next 3 */
36✔
912
            } else {
36✔
913
                this.removeAttribute('aria-label');
36✔
914
            }
36✔
915
        }
1✔
916
    }
3,890✔
917

36✔
918
    protected selectsChanged(): void {
36✔
919
        const updates: Promise<unknown>[] = [
303✔
920
            new Promise((res) => requestAnimationFrame(() => res(true))),
303✔
921
        ];
303✔
922
        this.childItemSet.forEach((childItem) => {
303✔
923
            updates.push(childItem.triggerUpdate());
19✔
924
        });
303✔
925
        this.childItemsUpdated = Promise.all(updates);
303✔
926
    }
303✔
927

36✔
928
    public override connectedCallback(): void {
36✔
929
        super.connectedCallback();
826✔
930
        if (!this.hasAttribute('role') && !this.ignore) {
826✔
931
            this.setAttribute('role', this.ownRole);
176✔
932
        }
176✔
933
        this.updateComplete.then(() => this.updateItemFocus());
826✔
934
    }
826✔
935

36✔
936
    private isFocusableElement(el: MenuItem): boolean {
36✔
937
        return el ? !el.disabled : false;
1,758✔
938
    }
1,758✔
939

36✔
940
    public override disconnectedCallback(): void {
36✔
941
        this.cachedChildItems = undefined;
826✔
942
        this.selectedItems = [];
826✔
943
        this.selectedItemsMap.clear();
826✔
944
        this.childItemSet.clear();
826✔
945
        this.descendentOverlays = new Map<Overlay, Overlay>();
826✔
946
        super.disconnectedCallback();
826✔
947
    }
826✔
948

36✔
949
    protected childItemsUpdated!: Promise<unknown[]>;
36✔
950
    protected cacheUpdated = Promise.resolve();
36✔
951
    /* c8 ignore next 3 */
36✔
952
    protected resolveCacheUpdated = (): void => {
36✔
953
        return;
36✔
954
    };
36✔
955

36✔
956
    protected override async getUpdateComplete(): Promise<boolean> {
36✔
957
        const complete = (await super.getUpdateComplete()) as boolean;
1,644✔
958
        await this.childItemsUpdated;
1,641✔
959
        await this.cacheUpdated;
1,641✔
960
        return complete;
1,641✔
961
    }
1,644✔
962
}
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

© 2026 Coveralls, Inc