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

adobe / spectrum-web-components / 13553164764

26 Feb 2025 08:53PM CUT coverage: 97.966% (-0.2%) from 98.185%
13553164764

Pull #5031

github

web-flow
Merge b7398ad1e into 191a15bd9
Pull Request #5031: fix(action menu): keyboard accessibility omnibus

5295 of 5602 branches covered (94.52%)

Branch coverage included in aggregate %.

627 of 678 new or added lines in 11 files covered. (92.48%)

27 existing lines in 8 files now uncovered.

33662 of 34164 relevant lines covered (98.53%)

644.21 hits per line

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

95.54
/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,008✔
114
    }
26,008✔
115

36✔
116
    public set selected(selected: string[]) {
36✔
117
        if (selected === this.selected) {
1,856!
118
            return;
×
119
        }
×
120
        const old = this.selected;
1,856✔
121
        this._selected = selected;
1,856✔
122
        this.selectedItems = [];
1,856✔
123
        this.selectedItemsMap.clear();
1,856✔
124
        this.childItems.forEach((item) => {
1,856✔
125
            if (this !== item.menuData.selectionRoot) {
8,415✔
126
                return;
58✔
127
            }
58✔
128
            item.selected = this.selected.includes(item.value);
8,357✔
129
            if (item.selected) {
8,367✔
130
                this.selectedItems.push(item);
133✔
131
                this.selectedItemsMap.set(item, true);
133✔
132
            }
133✔
133
        });
1,856✔
134
        this.requestUpdate('selected', old);
1,856✔
135
    }
1,856✔
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
    public get focusInItem(): MenuItem | undefined {
36✔
153
        return this.rovingTabindexController?.focusInElement;
1,699✔
154
    }
1,699✔
155

36✔
156
    protected get controlsRovingTabindex(): boolean {
36✔
157
        return true;
781✔
158
    }
781✔
159

36✔
160
    private selectedItemsMap = new Map<MenuItem, boolean>();
36✔
161

36✔
162
    /**
36✔
163
     * child items managed by menu
36✔
164
     */
36✔
165
    public get childItems(): MenuItem[] {
36✔
166
        if (!this.cachedChildItems) {
15,746✔
167
            this.cachedChildItems = this.updateCachedMenuItems();
1,904✔
168
        }
1,904✔
169
        return this.cachedChildItems;
15,746✔
170
    }
15,746✔
171

36✔
172
    private cachedChildItems: MenuItem[] | undefined;
36✔
173

36✔
174
    private updateCachedMenuItems(): MenuItem[] {
36✔
175
        if (!this.menuSlot) {
1,904✔
176
            return [];
655✔
177
        }
655✔
178
        const itemsList = [];
1,249✔
179
        const slottedElements = this.menuSlot.assignedElements({
1,249✔
180
            flatten: true,
1,249✔
181
        }) as HTMLElement[];
1,249✔
182
        // Recursively flatten <slot> and non-<sp-menu-item> elements assigned to the menu into a single array.
1,249✔
183
        for (const [i, slottedElement] of slottedElements.entries()) {
1,903✔
184
            if (this.childItemSet.has(slottedElement as MenuItem)) {
9,852✔
185
                // Assign <sp-menu-item> members of the array that are in this.childItemSet to this.chachedChildItems.
6,397✔
186
                itemsList.push(slottedElement as MenuItem);
6,397✔
187
                continue;
6,397✔
188
            }
6,397✔
189
            const isHTMLSlotElement = slottedElement.localName === 'slot';
3,455✔
190
            const flattenedChildren = isHTMLSlotElement
3,455✔
191
                ? (slottedElement as HTMLSlotElement).assignedElements({
114✔
192
                      flatten: true,
114✔
193
                  })
114✔
194
                : [...slottedElement.querySelectorAll(`:scope > *`)];
3,341✔
195
            slottedElements.splice(
9,852✔
196
                i,
9,852✔
197
                1,
9,852✔
198
                slottedElement,
9,852✔
199
                ...(flattenedChildren as HTMLElement[])
9,852✔
200
            );
9,852✔
201
        }
9,852✔
202

1,249✔
203
        this.cachedChildItems = [...itemsList];
1,249✔
204
        this.rovingTabindexController?.clearElementCache();
1,904✔
205

1,904✔
206
        return this.cachedChildItems;
1,904✔
207
    }
1,904✔
208

36✔
209
    /**
36✔
210
     * Hide this getter from web-component-analyzer until
36✔
211
     * https://github.com/runem/web-component-analyzer/issues/131
36✔
212
     * has been addressed.
36✔
213
     *
36✔
214
     * @private
36✔
215
     */
36✔
216
    public get childRole(): string {
36✔
217
        if (this.resolvedRole === 'listbox') {
7,088✔
218
            return 'option';
3,926✔
219
        }
3,926✔
220
        switch (this.resolvedSelects) {
3,162✔
221
            case 'single':
4,242✔
222
                return 'menuitemradio';
221✔
223
            case 'multiple':
7,088✔
224
                return 'menuitemcheckbox';
87✔
225
            default:
7,088✔
226
                return 'menuitem';
2,854✔
227
        }
7,088✔
228
    }
7,088✔
229

36✔
230
    protected get ownRole(): string {
36✔
231
        return 'menu';
126✔
232
    }
126✔
233

36✔
234
    /**
36✔
235
     * menuitem role based on selection type
36✔
236
     */
36✔
237
    private resolvedSelects?: SelectsType;
36✔
238

36✔
239
    /**
36✔
240
     * menu role based on selection type
36✔
241
     */
36✔
242
    private resolvedRole?: RoleType;
36✔
243

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

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

6,400✔
278
        event.item.menuData.parentMenu = event.item.menuData.parentMenu || this;
6,400✔
279
        this.addChildItem(event.item);
6,400✔
280

6,400✔
281
        if (this.selects === 'inherit') {
6,400✔
282
            this.resolvedSelects = 'inherit';
83✔
283
            const ignoreMenu = event.currentAncestorWithSelects?.ignore;
83!
284
            this.resolvedRole = ignoreMenu
83✔
285
                ? 'none'
42✔
286
                : ((event.currentAncestorWithSelects?.getAttribute('role') ||
41!
287
                      this.getAttribute('role') ||
×
288
                      undefined) as RoleType);
×
289
        } else if (this.selects) {
6,400✔
290
            this.resolvedRole = this.ignore
2,684!
291
                ? 'none'
×
292
                : ((this.getAttribute('role') || undefined) as RoleType);
2,684!
293
            this.resolvedSelects = this.selects;
2,684✔
294
        } else {
6,317✔
295
            this.resolvedRole = this.ignore
3,633!
UNCOV
296
                ? 'none'
×
297
                : ((this.getAttribute('role') || undefined) as RoleType);
3,633!
298
            this.resolvedSelects =
3,633✔
299
                this.resolvedRole === 'none' ? 'ignore' : 'none';
3,633✔
300
        }
3,633✔
301

6,400✔
302
        if (this.resolvedRole === 'none') {
6,400✔
303
            return;
66✔
304
        }
66✔
305

6,334✔
306
        const selects =
6,334✔
307
            this.resolvedSelects === 'single' ||
6,334✔
308
            this.resolvedSelects === 'multiple';
3,700✔
309
        event.item.menuData.cleanupSteps.push((item: MenuItem) =>
6,400✔
310
            this.removeChildItem(item)
5,555✔
311
        );
6,400✔
312
        if (
6,400✔
313
            (selects || (!this.selects && this.resolvedSelects !== 'ignore')) &&
6,400✔
314
            !event.item.menuData.selectionRoot
6,293✔
315
        ) {
6,400✔
316
            event.item.setRole(this.childRole);
6,154✔
317
            event.item.menuData.selectionRoot =
6,154✔
318
                event.item.menuData.selectionRoot || this;
6,154✔
319
            if (event.item.selected) {
6,154✔
320
                this.selectedItemsMap.set(event.item, true);
48✔
321
                this.selectedItems = [...this.selectedItems, event.item];
48✔
322
                this._selected = [...this.selected, event.item.value];
48✔
323
                this.value = this.selected.join(this.valueSeparator);
48✔
324
            }
48✔
325
        }
6,154✔
326
    }
6,400✔
327

36✔
328
    private addChildItem(item: MenuItem): void {
36✔
329
        this.childItemSet.add(item);
6,400✔
330
        this.handleItemsChanged();
6,400✔
331
    }
6,400✔
332

36✔
333
    private async removeChildItem(item: MenuItem): Promise<void> {
36✔
334
        if (item.focused || item.hasAttribute('focused') || item.active) {
5,555✔
335
            this._updateFocus = this.getNeighboringFocusableElement(item);
81✔
336
        }
81✔
337
        this.childItemSet.delete(item);
5,555✔
338
        this.cachedChildItems = undefined;
5,555✔
339
    }
5,555✔
340

36✔
341
    public constructor() {
36✔
342
        super();
833✔
343

833✔
344
        /**
833✔
345
         * only create an RTI if menu controls keyboard navigation and one does not already exist
833✔
346
         */
833✔
347
        if (!this.rovingTabindexController && this.controlsRovingTabindex) {
833✔
348
            this.rovingTabindexController =
781✔
349
                new RovingTabindexController<MenuItem>(this, {
781✔
350
                    direction: 'vertical',
781✔
351
                    focusInIndex: (elements: MenuItem[] | undefined) => {
781✔
352
                        let firstEnabledIndex = -1;
16,967✔
353
                        const firstSelectedIndex = elements?.findIndex(
16,967!
354
                            (el, index) => {
16,967✔
355
                                if (
1,499,087✔
356
                                    !elements[firstEnabledIndex] &&
1,499,087✔
357
                                    !el.disabled
16,648✔
358
                                ) {
1,499,087✔
359
                                    firstEnabledIndex = index;
16,596✔
360
                                }
16,596✔
361
                                return el.selected && !el.disabled;
1,499,087✔
362
                            }
1,499,087✔
363
                        );
16,967✔
364
                        return elements &&
16,967✔
365
                            firstSelectedIndex &&
16,967✔
366
                            elements[firstSelectedIndex]
16,663✔
367
                            ? firstSelectedIndex
1,194✔
368
                            : firstEnabledIndex;
15,773✔
369
                    },
16,967✔
370
                    elements: () => this.childItems,
781✔
371
                    isFocusableElement: this.isFocusableElement.bind(this),
781✔
372
                    hostDelegatesFocus: true,
781✔
373
                });
781✔
374
        }
781✔
375

833✔
376
        this.addEventListener(
833✔
377
            'sp-menu-item-added-or-updated',
833✔
378
            this.onSelectableItemAddedOrUpdated
833✔
379
        );
833✔
380
        this.addEventListener(
833✔
381
            'sp-menu-item-added-or-updated',
833✔
382
            this.onFocusableItemAddedOrUpdated,
833✔
383
            {
833✔
384
                capture: true,
833✔
385
            }
833✔
386
        );
833✔
387

833✔
388
        this.addEventListener('click', this.handleClick);
833✔
389
        this.addEventListener('focusout', this.handleFocusout);
833✔
390
        this.addEventListener('sp-menu-item-keydown', this.handleKeydown);
833✔
391
        this.addEventListener('pointerup', this.handlePointerup);
833✔
392
        this.addEventListener('sp-opened', this.handleSubmenuOpened);
833✔
393
        this.addEventListener('sp-closed', this.handleSubmenuClosed);
833✔
394
    }
833✔
395

36✔
396
    /**
36✔
397
     * for picker elements, will set focus on first selected item
36✔
398
     */
36✔
399
    public focusOnFirstSelectedItem({
36✔
400
        preventScroll,
98✔
401
    }: FocusOptions = {}): void {
98✔
402
        if (!this.rovingTabindexController) return;
98!
403
        const selectedItem = this.selectedItems.find((el) =>
98✔
404
            this.isFocusableElement(el)
11✔
405
        );
98✔
406
        if (!selectedItem) {
98✔
407
            this.focus({ preventScroll });
87✔
408
            return;
87✔
409
        }
87✔
410

11✔
411
        if (selectedItem && !preventScroll) {
98✔
412
            selectedItem.scrollIntoView({ block: 'nearest' });
11✔
413
        }
11✔
414
        this.rovingTabindexController?.focusOnItem(selectedItem);
98!
415
    }
98✔
416

36✔
417
    public override focus({ preventScroll }: FocusOptions = {}): void {
36✔
418
        if (this.rovingTabindexController) {
115✔
419
            if (
115✔
420
                !this.childItems.length ||
115✔
421
                this.childItems.every((childItem) => childItem.disabled)
114✔
422
            ) {
115✔
423
                return;
3✔
424
            }
3✔
425
            if (
112✔
426
                this.childItems.some(
112✔
427
                    (childItem) => childItem.menuData.focusRoot !== this
112✔
428
                )
112✔
429
            ) {
115!
NEW
430
                super.focus({ preventScroll });
×
NEW
431
                return;
×
NEW
432
            }
✔
433
            this.rovingTabindexController.focus({ preventScroll });
112✔
434
        }
112✔
435
    }
115✔
436

36✔
437
    // if the click and pointerup events are on the same target, we should not
36✔
438
    // handle the click event.
36✔
439
    private pointerUpTarget = null as EventTarget | null;
36✔
440

36✔
441
    private handleFocusout(): void {
36✔
442
        if (!this.matches(':focus-within'))
312✔
443
            this.rovingTabindexController?.reset();
312✔
444
    }
312✔
445

36✔
446
    private handleClick(event: Event): void {
36✔
447
        if (this.pointerUpTarget === event.target) {
238✔
448
            this.pointerUpTarget = null;
38✔
449
            return;
38✔
450
        }
38✔
451
        this.handlePointerBasedSelection(event);
200✔
452
    }
238✔
453

36✔
454
    private handlePointerup(event: Event): void {
36✔
455
        this.pointerUpTarget = event.target;
44✔
456
        this.handlePointerBasedSelection(event);
44✔
457
    }
44✔
458

36✔
459
    private handlePointerBasedSelection(event: Event): void {
36✔
460
        // Only handle left clicks
244✔
461
        if (event instanceof MouseEvent && event.button !== 0) {
244✔
462
            return;
2✔
463
        }
2✔
464

242✔
465
        const path = event.composedPath();
242✔
466
        const target = path.find((el) => {
242✔
467
            /* c8 ignore next 3 */
36✔
468
            if (!(el instanceof Element)) {
36✔
469
                return false;
36✔
470
            }
36✔
471
            return el.getAttribute('role') === this.childRole;
934✔
472
        }) as MenuItem;
242✔
473
        if (event.defaultPrevented) {
244✔
474
            const index = this.childItems.indexOf(target);
73✔
475
            if (target?.menuData?.focusRoot === this && index > -1) {
73✔
476
                this.focusedItemIndex = index;
10✔
477
            }
10✔
478
            return;
73✔
479
        }
73✔
480
        if (target?.href && target.href.length) {
244✔
481
            // This event will NOT ALLOW CANCELATION as link action
8✔
482
            // cancelation should occur on the `<sp-menu-item>` itself.
8✔
483
            this.dispatchEvent(
8✔
484
                new Event('change', {
8✔
485
                    bubbles: true,
8✔
486
                    composed: true,
8✔
487
                })
8✔
488
            );
8✔
489
            return;
8✔
490
        } else if (
8✔
491
            target?.menuData?.selectionRoot === this &&
161✔
492
            this.childItems.length
145✔
493
        ) {
161✔
494
            event.preventDefault();
145✔
495
            if (target.hasSubmenu || target.open) {
145!
UNCOV
496
                return;
×
UNCOV
497
            }
×
498
            this.selectOrToggleItem(target);
145✔
499
        } else {
161✔
500
            return;
16✔
501
        }
16✔
502
        this.prepareToCleanUp();
145✔
503
    }
244✔
504

36✔
505
    private descendentOverlays = new Map<Overlay, Overlay>();
36✔
506

36✔
507
    protected handleDescendentOverlayOpened(event: Event): void {
36✔
508
        const target = event.composedPath()[0] as MenuItem;
46✔
509
        /* c8 ignore next 1 */
36✔
510
        if (!target.overlayElement) return;
36✔
511
        this.descendentOverlays.set(
46✔
512
            target.overlayElement,
46✔
513
            target.overlayElement
46✔
514
        );
46✔
515
    }
46✔
516

36✔
517
    protected handleDescendentOverlayClosed(event: Event): void {
36✔
518
        const target = event.composedPath()[0] as MenuItem;
47✔
519
        /* c8 ignore next 1 */
36✔
520
        if (!target.overlayElement) return;
36✔
521
        this.descendentOverlays.delete(target.overlayElement);
47✔
522
    }
47✔
523

36✔
524
    public handleSubmenuClosed = (event: Event): void => {
36✔
525
        event.stopPropagation();
43✔
526
        const target = event.composedPath()[0] as Overlay;
43✔
527
        target.dispatchEvent(
43✔
528
            new Event('sp-menu-submenu-closed', {
43✔
529
                bubbles: true,
43✔
530
                composed: true,
43✔
531
            })
43✔
532
        );
43✔
533
    };
43✔
534

36✔
535
    /**
36✔
536
     * given a menu item, returns the next focusable menu item before or after it;
36✔
537
     * if no menu item is provided, returns the first focusable menu item
36✔
538
     * @param menuItem {MenuItem}
36✔
539
     * @param before {boolean} return the item before; default is false
36✔
540
     * @returns {MenuItem}
36✔
541
     */
36✔
542
    public getNeighboringFocusableElement(
36✔
543
        menuItem?: MenuItem,
97✔
544
        before = false
97✔
545
    ): MenuItem {
97✔
546
        const diff = before ? -1 : 1;
97✔
547
        const elements = this.rovingTabindexController?.elements || [];
97✔
548
        const index = !!menuItem ? elements.indexOf(menuItem) : -1;
97✔
549
        let newIndex = Math.min(Math.max(0, index + diff), elements.length - 1);
97✔
550
        while (
97✔
551
            !this.isFocusableElement(elements[newIndex]) &&
97✔
552
            0 < newIndex &&
16✔
553
            newIndex < elements.length - 1
7✔
554
        ) {
97✔
555
            newIndex += diff;
1✔
556
        }
1✔
557
        return !!this.isFocusableElement(elements[newIndex])
97✔
558
            ? (elements[newIndex] as MenuItem)
82✔
559
            : menuItem || elements[0];
15!
560
    }
97✔
561

36✔
562
    public handleSubmenuOpened = (event: Event): void => {
36✔
563
        event.stopPropagation();
42✔
564
        const target = event.composedPath()[0] as Overlay;
42✔
565
        target.dispatchEvent(
42✔
566
            new Event('sp-menu-submenu-opened', {
42✔
567
                bubbles: true,
42✔
568
                composed: true,
42✔
569
            })
42✔
570
        );
42✔
571

42✔
572
        const openedItem = event
42✔
573
            .composedPath()
42✔
574
            .find((el) => this.childItemSet.has(el as MenuItem));
42✔
575
        /* c8 ignore next 1 */
36✔
576
        if (!openedItem) return;
36✔
577
    };
42✔
578

36✔
579
    public async selectOrToggleItem(targetItem: MenuItem): Promise<void> {
36✔
580
        const resolvedSelects = this.resolvedSelects;
196✔
581
        const oldSelectedItemsMap = new Map(this.selectedItemsMap);
196✔
582
        const oldSelected = this.selected.slice();
196✔
583
        const oldSelectedItems = this.selectedItems.slice();
196✔
584
        const oldValue = this.value;
196✔
585

196✔
586
        if (targetItem.menuData.selectionRoot !== this) {
196✔
587
            return;
13✔
588
        }
13✔
589

183✔
590
        if (resolvedSelects === 'multiple') {
194✔
591
            if (this.selectedItemsMap.has(targetItem)) {
42✔
592
                this.selectedItemsMap.delete(targetItem);
20✔
593
            } else {
26✔
594
                this.selectedItemsMap.set(targetItem, true);
22✔
595
            }
22✔
596

42✔
597
            // Match HTML select and set the first selected
42✔
598
            // item as the value. Also set the selected array
42✔
599
            // in the order of the menu items.
42✔
600
            const selected: string[] = [];
42✔
601
            const selectedItems: MenuItem[] = [];
42✔
602

42✔
603
            this.childItemSet.forEach((childItem) => {
42✔
604
                if (childItem.menuData.selectionRoot !== this) return;
123!
605

123✔
606
                if (this.selectedItemsMap.has(childItem)) {
123✔
607
                    selected.push(childItem.value);
56✔
608
                    selectedItems.push(childItem);
56✔
609
                }
56✔
610
            });
42✔
611
            this._selected = selected;
42✔
612
            this.selectedItems = selectedItems;
42✔
613
            this.value = this.selected.join(this.valueSeparator);
42✔
614
        } else {
196✔
615
            this.selectedItemsMap.clear();
141✔
616
            this.selectedItemsMap.set(targetItem, true);
141✔
617
            this.value = targetItem.value;
141✔
618
            this._selected = [targetItem.value];
141✔
619
            this.selectedItems = [targetItem];
141✔
620
        }
141✔
621

183✔
622
        const applyDefault = this.dispatchEvent(
183✔
623
            new Event('change', {
183✔
624
                cancelable: true,
183✔
625
                bubbles: true,
183✔
626
                composed: true,
183✔
627
            })
183✔
628
        );
183✔
629
        if (!applyDefault) {
196✔
630
            // Cancel the event & don't apply the selection
10✔
631
            this._selected = oldSelected;
10✔
632
            this.selectedItems = oldSelectedItems;
10✔
633
            this.selectedItemsMap = oldSelectedItemsMap;
10✔
634
            this.value = oldValue;
10✔
635
            return;
10✔
636
        }
10✔
637
        // Apply the selection changes to the menu items
173✔
638
        if (resolvedSelects === 'single') {
191✔
639
            for (const oldItem of oldSelectedItemsMap.keys()) {
73✔
640
                if (oldItem !== targetItem) {
32✔
641
                    oldItem.selected = false;
26✔
642
                }
26✔
643
            }
32✔
644
            targetItem.selected = true;
73✔
645
        } else if (resolvedSelects === 'multiple') {
188✔
646
            targetItem.selected = !targetItem.selected;
38✔
647
        } else if (
38✔
648
            !targetItem.hasSubmenu &&
62✔
649
            targetItem?.menuData?.focusRoot === this
34!
650
        ) {
62✔
651
            this.dispatchEvent(new Event('close', { bubbles: true }));
30✔
652
        }
30✔
653
    }
196✔
654

36✔
655
    protected navigateBetweenRelatedMenus(event: MenuItemKeydownEvent): void {
36✔
656
        const { key, root } = event;
89✔
657
        const shouldOpenSubmenu =
89✔
658
            (this.isLTR && key === 'ArrowRight') ||
89✔
659
            (!this.isLTR && key === 'ArrowLeft');
83✔
660
        const shouldCloseSelfAsSubmenu =
89✔
661
            (this.isLTR && key === 'ArrowLeft') ||
89✔
662
            (!this.isLTR && key === 'ArrowRight') ||
85✔
663
            key === 'Escape';
83✔
664
        const lastFocusedItem = root as MenuItem;
89✔
665
        if (shouldOpenSubmenu) {
89✔
666
            if (lastFocusedItem?.hasSubmenu) {
10!
667
                //open submenu and set focus
10✔
668
                event.stopPropagation();
10✔
669
                lastFocusedItem.openOverlay();
10✔
670
            }
10✔
671
        } else if (shouldCloseSelfAsSubmenu && this.isSubmenu) {
89✔
672
            event.stopPropagation();
6✔
673
            this.dispatchEvent(new Event('close', { bubbles: true }));
6✔
674
            this.updateSelectedItemIndex();
6✔
675
        }
6✔
676
    }
89✔
677

36✔
678
    public handleKeydown(event: Event): void {
36✔
679
        if (event.defaultPrevented || !this.rovingTabindexController) {
152✔
680
            return;
34✔
681
        }
34✔
682
        const { key, root, shiftKey, target } = event as MenuItemKeydownEvent;
118✔
683
        const openSubmenuKey = ['Enter', ' '].includes(key);
118✔
684
        if (shiftKey && target !== this && this.hasAttribute('tabindex')) {
152!
685
            this.removeAttribute('tabindex');
×
686
            const replaceTabindex = (
×
687
                event: FocusEvent | KeyboardEvent
×
688
            ): void => {
×
689
                if (
×
690
                    !(event as KeyboardEvent).shiftKey &&
×
691
                    !this.hasAttribute('tabindex')
×
692
                ) {
×
693
                    document.removeEventListener('keyup', replaceTabindex);
×
694
                    this.removeEventListener('focusout', replaceTabindex);
×
695
                }
×
696
            };
×
697
            document.addEventListener('keyup', replaceTabindex);
×
698
            this.addEventListener('focusout', replaceTabindex);
×
699
        }
✔
700
        if (key === 'Tab') {
152✔
701
            this.closeDescendentOverlays();
5✔
702
            return;
5✔
703
        }
5✔
704
        if (openSubmenuKey && root?.hasSubmenu && !root.open) {
152✔
705
            // Remove focus while opening overlay from keyboard or the visible focus
1✔
706
            // will slip back to the first item in the menu.
1✔
707
            event.preventDefault();
1✔
708
            root.openOverlay();
1✔
709
            return;
1✔
710
        }
1✔
711
        if (key === ' ' || key === 'Enter') {
152✔
712
            event.preventDefault();
23✔
713
            root?.focusElement?.click();
23!
714
            if (root) this.selectOrToggleItem(root);
23✔
715
            return;
23✔
716
        }
23✔
717
        this.navigateBetweenRelatedMenus(event as MenuItemKeydownEvent);
89✔
718
    }
152✔
719

36✔
720
    private _hasUpdatedSelectedItemIndex = false;
36✔
721

36✔
722
    /**
36✔
723
     * on focus, removes focus from focus styling item, and updates the selected item index
36✔
724
     */
36✔
725
    private prepareToCleanUp(): void {
36✔
726
        document.addEventListener(
145✔
727
            'focusout',
145✔
728
            () => {
145✔
729
                requestAnimationFrame(() => {
135✔
730
                    const focusedItem = this.focusInItem;
134✔
731
                    if (focusedItem) {
134✔
732
                        focusedItem.focused = false;
64✔
733
                    }
64✔
734
                });
135✔
735
            },
135✔
736
            { once: true }
145✔
737
        );
145✔
738
    }
145✔
739

36✔
740
    public updateSelectedItemIndex(): void {
36✔
741
        let firstOrFirstSelectedIndex = 0;
954✔
742
        const selectedItemsMap = new Map<MenuItem, boolean>();
954✔
743
        const selected: string[] = [];
954✔
744
        const selectedItems: MenuItem[] = [];
954✔
745
        let itemIndex = this.childItems.length;
954✔
746
        while (itemIndex) {
954✔
747
            itemIndex -= 1;
6,973✔
748
            const childItem = this.childItems[itemIndex];
6,973✔
749
            if (childItem.menuData.selectionRoot === this) {
6,973✔
750
                if (
6,741✔
751
                    childItem.selected ||
6,741✔
752
                    (!this._hasUpdatedSelectedItemIndex &&
6,656✔
753
                        this.selected.includes(childItem.value))
6,656✔
754
                ) {
6,741✔
755
                    firstOrFirstSelectedIndex = itemIndex;
92✔
756
                    selectedItemsMap.set(childItem, true);
92✔
757
                    selected.unshift(childItem.value);
92✔
758
                    selectedItems.unshift(childItem);
92✔
759
                }
92✔
760
                // Remove "focused" from non-"selected" items ONLY
6,741✔
761
                // Preserve "focused" on index===0 when no selection
6,741✔
762
                if (itemIndex !== firstOrFirstSelectedIndex) {
6,741✔
763
                    childItem.focused = false;
5,930✔
764
                }
5,930✔
765
            }
6,741✔
766
        }
6,973✔
767

954✔
768
        this.selectedItemsMap = selectedItemsMap;
954✔
769
        this._selected = selected;
954✔
770
        this.selectedItems = selectedItems;
954✔
771
        this.value = this.selected.join(this.valueSeparator);
954✔
772
        this.focusedItemIndex = firstOrFirstSelectedIndex;
954✔
773
        this.focusInItemIndex = firstOrFirstSelectedIndex;
954✔
774
    }
954✔
775

36✔
776
    private _willUpdateItems = false;
36✔
777
    private _updateFocus?: MenuItem;
36✔
778

36✔
779
    private handleItemsChanged(): void {
36✔
780
        this.cachedChildItems = undefined;
6,400✔
781
        if (!this._willUpdateItems) {
6,400✔
782
            this._willUpdateItems = true;
766✔
783
            this.cacheUpdated = this.updateCache();
766✔
784
        }
766✔
785
    }
6,400✔
786

36✔
787
    private async updateCache(): Promise<void> {
36✔
788
        if (!this.hasUpdated) {
766!
789
            await Promise.all([
×
790
                new Promise((res) => requestAnimationFrame(() => res(true))),
×
791
                this.updateComplete,
×
792
            ]);
×
793
        } else {
766✔
794
            await new Promise((res) => requestAnimationFrame(() => res(true)));
766✔
795
        }
766✔
796
        if (this.cachedChildItems === undefined) {
766✔
797
            this.updateSelectedItemIndex();
740✔
798
            this.updateItemFocus();
740✔
799
        }
740✔
800

766✔
801
        this._willUpdateItems = false;
766✔
802
    }
766✔
803

36✔
804
    private updateItemFocus(): void {
36✔
805
        this.focusInItem?.setAttribute('tabindex', '0');
1,565✔
806
        if (this.childItems.length == 0) {
1,565✔
807
            return;
325✔
808
        }
325✔
809
    }
1,565✔
810

36✔
811
    public closeDescendentOverlays(): void {
36✔
812
        this.descendentOverlays.forEach((overlay) => {
216✔
813
            overlay.open = false;
11✔
814
        });
216✔
815
        this.descendentOverlays = new Map<Overlay, Overlay>();
216✔
816
    }
216✔
817

36✔
818
    private handleSlotchange({
36✔
819
        target,
1,043✔
820
    }: Event & { target: HTMLSlotElement }): void {
1,043✔
821
        const assignedElements = target.assignedElements({
1,043✔
822
            flatten: true,
1,043✔
823
        }) as MenuItem[];
1,043✔
824
        if (this.childItems.length !== assignedElements.length) {
1,043✔
825
            assignedElements.forEach((item) => {
806✔
826
                if (typeof item.triggerUpdate !== 'undefined') {
6,960✔
827
                    item.triggerUpdate();
6,527✔
828
                } else if (
6,527✔
829
                    typeof (item as unknown as Menu).childItems !== 'undefined'
433✔
830
                ) {
433✔
831
                    (item as unknown as Menu).childItems.forEach((child) => {
68✔
832
                        child.triggerUpdate();
28✔
833
                    });
68✔
834
                }
68✔
835
            });
806✔
836
        }
806✔
837
        if (!!this._updateFocus) {
1,043✔
838
            this.rovingTabindexController?.focusOnItem(this._updateFocus);
3!
839
            this._updateFocus = undefined;
3✔
840
        }
3✔
841
    }
1,043✔
842

36✔
843
    protected renderMenuItemSlot(): TemplateResult {
36✔
844
        return html`
3,882✔
845
            <slot
3,882✔
846
                @sp-menu-submenu-opened=${this.handleDescendentOverlayOpened}
3,882✔
847
                @sp-menu-submenu-closed=${this.handleDescendentOverlayClosed}
3,882✔
848
                @slotchange=${this.handleSlotchange}
3,882✔
849
            ></slot>
3,882✔
850
        `;
3,882✔
851
    }
3,882✔
852

36✔
853
    public override render(): TemplateResult {
36✔
854
        return this.renderMenuItemSlot();
3,641✔
855
    }
3,641✔
856

36✔
857
    protected override firstUpdated(changed: PropertyValues): void {
36✔
858
        super.firstUpdated(changed);
819✔
859
        const updates: Promise<unknown>[] = [
819✔
860
            new Promise((res) => requestAnimationFrame(() => res(true))),
819✔
861
        ];
819✔
862
        [...this.children].forEach((item) => {
819✔
863
            if ((item as MenuItem).localName === 'sp-menu-item') {
1,131✔
864
                updates.push((item as MenuItem).updateComplete);
414✔
865
            }
414✔
866
        });
819✔
867
        this.childItemsUpdated = Promise.all(updates);
819✔
868
    }
819✔
869

36✔
870
    protected override updated(changes: PropertyValues<this>): void {
36✔
871
        super.updated(changes);
3,882✔
872
        if (changes.has('selects') && this.hasUpdated) {
3,882✔
873
            this.selectsChanged();
303✔
874
        }
303✔
875
        if (
3,882✔
876
            changes.has('label') &&
3,882✔
877
            (this.label || typeof changes.get('label') !== 'undefined')
819✔
878
        ) {
3,882✔
879
            if (this.label) {
1✔
880
                this.setAttribute('aria-label', this.label);
1✔
881
                /* c8 ignore next 3 */
36✔
882
            } else {
36✔
883
                this.removeAttribute('aria-label');
36✔
884
            }
36✔
885
        }
1✔
886
    }
3,882✔
887

36✔
888
    protected selectsChanged(): void {
36✔
889
        const updates: Promise<unknown>[] = [
303✔
890
            new Promise((res) => requestAnimationFrame(() => res(true))),
303✔
891
        ];
303✔
892
        this.childItemSet.forEach((childItem) => {
303✔
893
            updates.push(childItem.triggerUpdate());
19✔
894
        });
303✔
895
        this.childItemsUpdated = Promise.all(updates);
303✔
896
    }
303✔
897

36✔
898
    public override connectedCallback(): void {
36✔
899
        super.connectedCallback();
825✔
900
        if (!this.hasAttribute('role') && !this.ignore) {
825✔
901
            this.setAttribute('role', this.ownRole);
174✔
902
        }
174✔
903
        this.updateComplete.then(() => this.updateItemFocus());
825✔
904
    }
825✔
905

36✔
906
    private isFocusableElement(el: MenuItem): boolean {
36✔
907
        return el ? !el.disabled : false;
1,496✔
908
    }
1,496✔
909

36✔
910
    public override disconnectedCallback(): void {
36✔
911
        this.cachedChildItems = undefined;
825✔
912
        this.selectedItems = [];
825✔
913
        this.selectedItemsMap.clear();
825✔
914
        this.childItemSet.clear();
825✔
915
        this.descendentOverlays = new Map<Overlay, Overlay>();
825✔
916
        super.disconnectedCallback();
825✔
917
    }
825✔
918

36✔
919
    protected childItemsUpdated!: Promise<unknown[]>;
36✔
920
    protected cacheUpdated = Promise.resolve();
36✔
921
    /* c8 ignore next 3 */
36✔
922
    protected resolveCacheUpdated = (): void => {
36✔
923
        return;
36✔
924
    };
36✔
925

36✔
926
    protected override async getUpdateComplete(): Promise<boolean> {
36✔
927
        const complete = (await super.getUpdateComplete()) as boolean;
1,646✔
928
        await this.childItemsUpdated;
1,643✔
929
        await this.cacheUpdated;
1,643✔
930
        return complete;
1,643✔
931
    }
1,646✔
932
}
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