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

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

34✔
13
import {
34✔
14
    CSSResultArray,
34✔
15
    html,
34✔
16
    nothing,
34✔
17
    PropertyValues,
34✔
18
    TemplateResult,
34✔
19
} from '@spectrum-web-components/base';
34✔
20
import {
34✔
21
    ObserveSlotPresence,
34✔
22
    ObserveSlotText,
34✔
23
    randomID,
34✔
24
} from '@spectrum-web-components/shared';
34✔
25
import {
34✔
26
    property,
34✔
27
    query,
34✔
28
} from '@spectrum-web-components/base/src/decorators.js';
34✔
29

34✔
30
import '@spectrum-web-components/icons-ui/icons/sp-icon-checkmark100.js';
34✔
31
import { LikeAnchor } from '@spectrum-web-components/shared/src/like-anchor.js';
34✔
32
import { Focusable } from '@spectrum-web-components/shared/src/focusable.js';
34✔
33
import '@spectrum-web-components/icons-ui/icons/sp-icon-chevron100.js';
34✔
34
import chevronStyles from '@spectrum-web-components/icon/src/spectrum-icon-chevron.css.js';
34✔
35
import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js';
34✔
36

34✔
37
import menuItemStyles from './menu-item.css.js';
34✔
38
import checkmarkStyles from '@spectrum-web-components/icon/src/spectrum-icon-checkmark.css.js';
34✔
39
import type { Menu } from './Menu.js';
34✔
40
import { MutationController } from '@lit-labs/observers/mutation-controller.js';
34✔
41
import type { Overlay } from '@spectrum-web-components/overlay';
34✔
42
import { SlottableRequestEvent } from '@spectrum-web-components/overlay/src/slottable-request-event.js';
34✔
43

34✔
44
/**
34✔
45
 * Duration during which a pointing device can leave an `<sp-menu-item>` element
34✔
46
 * and return to it or to the submenu opened from it before closing that submenu.
34✔
47
 **/
34✔
48
const POINTERLEAVE_TIMEOUT = 100;
34✔
49

34✔
50
type MenuCascadeItem = {
34✔
51
    hadFocusRoot: boolean;
34✔
52
    ancestorWithSelects?: HTMLElement;
34✔
53
};
34✔
54

34✔
55
/**
34✔
56
 * Fires when a menu item is added or updated so that a parent menu can track it.
34✔
57
 */
34✔
58
export class MenuItemAddedOrUpdatedEvent extends Event {
34✔
59
    constructor(item: MenuItem) {
34✔
60
        super('sp-menu-item-added-or-updated', {
6,177✔
61
            bubbles: true,
6,177✔
62
            composed: true,
6,177✔
63
        });
6,177✔
64
        this.clear(item);
6,177✔
65
    }
6,177✔
66
    clear(item: MenuItem): void {
34✔
67
        this._item = item;
6,357✔
68
        this.currentAncestorWithSelects = undefined;
6,357✔
69
        item.menuData = {
6,357✔
70
            cleanupSteps: [],
6,357✔
71
            focusRoot: undefined,
6,357✔
72
            selectionRoot: undefined,
6,357✔
73
            parentMenu: undefined,
6,357✔
74
        };
6,357✔
75
        this.menuCascade = new WeakMap<HTMLElement, MenuCascadeItem>();
6,357✔
76
    }
6,357✔
77
    menuCascade = new WeakMap<HTMLElement, MenuCascadeItem>();
34✔
78
    get item(): MenuItem {
34✔
79
        return this._item;
76,651✔
80
    }
76,651✔
81
    private _item!: MenuItem;
34✔
82
    currentAncestorWithSelects?: Menu;
34✔
83
}
34✔
84

34✔
85
/**
34✔
86
 * Fires to forward keyboard event information to parent menu.
34✔
87
 */
34✔
88
export class MenuItemKeydownEvent extends KeyboardEvent {
34✔
89
    root?: MenuItem;
34✔
90
    private _event?: KeyboardEvent;
34✔
91
    constructor({ root, event }: { root?: MenuItem; event?: KeyboardEvent }) {
34✔
92
        super('sp-menu-item-keydown', { bubbles: true, composed: true });
107✔
93
        this.root = root;
107✔
94
        this._event = event;
107✔
95
    }
107✔
96

34✔
97
    public override get altKey(): boolean {
34✔
NEW
98
        return this._event?.altKey || false;
×
NEW
99
    }
×
100

34✔
101
    public override get code(): string {
34✔
NEW
102
        return this._event?.code || '';
×
NEW
103
    }
×
104

34✔
105
    public override get ctrlKey(): boolean {
34✔
NEW
106
        return this._event?.ctrlKey || false;
×
NEW
107
    }
×
108

34✔
109
    public override get isComposing(): boolean {
34✔
NEW
110
        return this._event?.isComposing || false;
×
NEW
111
    }
×
112

34✔
113
    public override get key(): string {
34✔
114
        return this._event?.key || '';
244!
115
    }
244✔
116

34✔
117
    public override get location(): number {
34✔
NEW
118
        return this._event?.location || 0;
×
NEW
119
    }
×
120

34✔
121
    public override get metaKey(): boolean {
34✔
NEW
122
        return this._event?.metaKey || false;
×
NEW
123
    }
×
124

34✔
125
    public override get repeat(): boolean {
34✔
NEW
126
        return this._event?.repeat || false;
×
NEW
127
    }
×
128

34✔
129
    public override get shiftKey(): boolean {
34✔
130
        return this._event?.shiftKey || false;
118!
131
    }
118✔
132
}
34✔
133

34✔
134
export type MenuItemChildren = { icon: Element[]; content: Node[] };
34✔
135

34✔
136
/**
34✔
137
 * @element sp-menu-item
34✔
138
 *
34✔
139
 * @slot - text content to display within the Menu Item
34✔
140
 * @slot description - description to be placed below the label of the Menu Item
34✔
141
 * @slot icon - icon element to be placed at the start of the Menu Item
34✔
142
 * @slot value - content placed at the end of the Menu Item like values, keyboard shortcuts, etc.
34✔
143
 * @slot submenu - content placed in a submenu
34✔
144
 * @fires sp-menu-item-added - announces the item has been added so a parent menu can take ownerships
34✔
145
 */
34✔
146
export class MenuItem extends LikeAnchor(
34✔
147
    ObserveSlotText(ObserveSlotPresence(Focusable, '[slot="icon"]'))
34✔
148
) {
34✔
149
    public static override get styles(): CSSResultArray {
34✔
150
        return [menuItemStyles, checkmarkStyles, chevronStyles];
34✔
151
    }
34✔
152

34✔
153
    abortControllerSubmenu!: AbortController;
34✔
154

34✔
155
    /**
34✔
156
     * whether the menu item is active or has an active descendant
34✔
157
     */
34✔
158
    @property({ type: Boolean, reflect: true })
34✔
159
    public active = false;
34✔
160

34✔
161
    private dependencyManager = new DependencyManagerController(this);
34✔
162

34✔
163
    /**
34✔
164
     * whether the menu item has keyboard focus
34✔
165
     */
34✔
166
    @property({ type: Boolean, reflect: true })
34✔
167
    public focused = false;
34✔
168

34✔
169
    /**
34✔
170
     * whether the menu item is selected
34✔
171
     */
34✔
172
    @property({ type: Boolean, reflect: true })
34✔
173
    public selected = false;
34✔
174

34✔
175
    /**
34✔
176
     * value of the menu item which is used for selection
34✔
177
     */
34✔
178
    @property({ type: String })
34✔
179
    public get value(): string {
34✔
180
        return this._value || this.itemText;
38,976✔
181
    }
38,976✔
182

34✔
183
    public set value(value: string) {
34✔
184
        if (value === this._value) {
5,066✔
185
            return;
2,066✔
186
        }
2,066✔
187
        this._value = value || '';
5,066✔
188
        if (this._value) {
5,066✔
189
            this.setAttribute('value', this._value);
2,998✔
190
        } else {
5,062✔
191
            this.removeAttribute('value');
2✔
192
        }
2✔
193
    }
5,066✔
194

34✔
195
    private _value = '';
34✔
196

34✔
197
    /**
34✔
198
     * @private
34✔
199
     * text content of the menu item minus whitespace
34✔
200
     */
34✔
201
    public get itemText(): string {
34✔
202
        return this.itemChildren.content.reduce(
16,601✔
203
            (acc, node) => acc + (node.textContent || '').trim(),
16,601✔
204
            ''
16,601✔
205
        );
16,601✔
206
    }
16,601✔
207

34✔
208
    /**
34✔
209
     * whether the menu item has a submenu
34✔
210
     */
34✔
211
    @property({ type: Boolean, reflect: true, attribute: 'has-submenu' })
34✔
212
    public hasSubmenu = false;
34✔
213

34✔
214
    @query('slot:not([name])')
34✔
215
    contentSlot!: HTMLSlotElement;
34✔
216

34✔
217
    @query('slot[name="icon"]')
34✔
218
    iconSlot!: HTMLSlotElement;
34✔
219

34✔
220
    /**
34✔
221
     * whether menu item text content should not wrap
34✔
222
     */
34✔
223
    @property({
34✔
224
        type: Boolean,
34✔
225
        reflect: true,
34✔
226
        attribute: 'no-wrap',
34✔
227
        hasChanged() {
34✔
228
            return false;
5,703✔
229
        },
5,703✔
230
    })
34✔
231
    public noWrap = false;
34✔
232

34✔
233
    @query('.anchor')
34✔
234
    private anchorElement!: HTMLAnchorElement;
34✔
235

34✔
236
    @query('sp-overlay')
34✔
237
    public overlayElement!: Overlay;
34✔
238

34✔
239
    private submenuElement?: HTMLElement;
34✔
240

34✔
241
    /**
34✔
242
     * the focusable element of the menu item
34✔
243
     */
34✔
244
    public override get focusElement(): HTMLElement {
34✔
245
        return this;
53,032✔
246
    }
53,032✔
247

34✔
248
    protected get hasIcon(): boolean {
34✔
249
        return this.slotContentIsPresent;
240✔
250
    }
240✔
251

34✔
252
    public get itemChildren(): MenuItemChildren {
34✔
253
        if (!this.iconSlot || !this.contentSlot) {
16,738✔
254
            return {
8,409✔
255
                icon: [],
8,409✔
256
                content: [],
8,409✔
257
            };
8,409✔
258
        }
8,409✔
259
        if (this._itemChildren) {
16,362✔
260
            return this._itemChildren;
5,669✔
261
        }
5,669✔
262
        const icon = this.iconSlot.assignedElements().map((element) => {
2,660✔
263
            const newElement = element.cloneNode(true) as HTMLElement;
8✔
264
            newElement.removeAttribute('slot');
8✔
265
            newElement.classList.toggle('icon');
8✔
266
            return newElement;
8✔
267
        });
2,660✔
268
        const content = this.contentSlot
2,660✔
269
            .assignedNodes()
2,660✔
270
            .map((node) => node.cloneNode(true));
2,660✔
271
        this._itemChildren = { icon, content };
2,660✔
272

2,660✔
273
        return this._itemChildren;
2,660✔
274
    }
16,738✔
275

34✔
276
    private _itemChildren?: MenuItemChildren;
34✔
277

34✔
278
    constructor() {
34✔
279
        super();
5,703✔
280
        this.addEventListener('click', this.handleClickCapture, {
5,703✔
281
            capture: true,
5,703✔
282
        });
5,703✔
283
        this.addEventListener('focus', this.handleFocus);
5,703✔
284
        this.addEventListener('blur', this.handleBlur);
5,703✔
285

5,703✔
286
        new MutationController(this, {
5,703✔
287
            config: {
5,703✔
288
                characterData: true,
5,703✔
289
                childList: true,
5,703✔
290
                subtree: true,
5,703✔
291
                attributeFilter: ['src'],
5,703✔
292
            },
5,703✔
293
            callback: (mutations) => {
5,703✔
294
                const isSubmenu = mutations.every(
5,773✔
295
                    (mutation) =>
5,773✔
296
                        (mutation.target as HTMLElement).slot === 'submenu'
46✔
297
                );
5,773✔
298
                if (isSubmenu) {
5,773✔
299
                    return;
5,743✔
300
                }
5,743✔
301
                this.breakItemChildrenCache();
30✔
302
            },
5,773✔
303
        });
5,703✔
304
    }
5,703✔
305

34✔
306
    /**
34✔
307
     * whether submenu is open
34✔
308
     */
34✔
309
    @property({ type: Boolean, reflect: true })
34✔
310
    public open = false;
34✔
311

34✔
312
    private handleClickCapture(event: Event): void | boolean {
34✔
313
        if (this.disabled) {
175✔
314
            event.preventDefault();
1✔
315
            event.stopImmediatePropagation();
1✔
316
            event.stopPropagation();
1✔
317
            return false;
1✔
318
        }
1✔
319

174✔
320
        if (this.shouldProxyClick()) {
174✔
321
            return;
8✔
322
        }
8✔
323
    }
175✔
324

34✔
325
    private handleSlottableRequest = (event: SlottableRequestEvent): void => {
34✔
326
        this.submenuElement?.dispatchEvent(
68!
327
            new SlottableRequestEvent(event.name, event.data)
68✔
328
        );
68✔
329
    };
68✔
330

34✔
331
    private proxyFocus = (): void => {
34✔
332
        this.focus();
11✔
333
    };
11✔
334

34✔
335
    private shouldProxyClick(): boolean {
34✔
336
        let handled = false;
174✔
337
        if (this.anchorElement) {
174✔
338
            this.anchorElement.click();
8✔
339
            handled = true;
8✔
340
        }
8✔
341
        return handled;
174✔
342
    }
174✔
343

34✔
344
    protected breakItemChildrenCache(): void {
34✔
345
        this._itemChildren = undefined;
30✔
346
        this.triggerUpdate();
30✔
347
    }
30✔
348

34✔
349
    protected renderSubmenu(): TemplateResult {
34✔
350
        const slot = html`
6,700✔
351
            <slot
6,700✔
352
                name="submenu"
6,700✔
353
                @slotchange=${this.manageSubmenu}
6,700✔
354
                @sp-menu-item-added-or-updated=${{
6,700✔
355
                    handleEvent: (event: MenuItemAddedOrUpdatedEvent) => {
6,700✔
356
                        event.clear(event.item);
180✔
357
                    },
180✔
358
                    capture: true,
6,700✔
359
                }}
6,700✔
360
                @focusin=${(event: Event) => event.stopPropagation()}
6,700✔
361
            ></slot>
6,700✔
362
        `;
6,700✔
363
        if (!this.hasSubmenu) {
6,700✔
364
            return slot;
6,471✔
365
        }
6,471✔
366
        this.dependencyManager.add('sp-overlay');
229✔
367
        this.dependencyManager.add('sp-popover');
229✔
368
        import('@spectrum-web-components/overlay/sp-overlay.js');
229✔
369
        import('@spectrum-web-components/popover/sp-popover.js');
229✔
370
        return html`
229✔
371
            <sp-overlay
229✔
372
                .triggerElement=${this as HTMLElement}
229✔
373
                ?disabled=${!this.hasSubmenu}
229✔
374
                ?open=${this.hasSubmenu &&
229✔
375
                this.open &&
229✔
376
                this.dependencyManager.loaded}
6,700✔
377
                .placement=${this.isLTR ? 'right-start' : 'left-start'}
6,700✔
378
                .offset=${[-10, -5] as [number, number]}
6,700✔
379
                .type=${'auto'}
6,700✔
380
                @close=${(event: Event) => event.stopPropagation()}
6,700✔
381
                @slottable-request=${this.handleSlottableRequest}
6,700✔
382
            >
6,700✔
383
                <sp-popover
6,700✔
384
                    @change=${(event: Event) => {
6,700✔
385
                        this.handleSubmenuChange(event);
28✔
386
                        this.open = false;
28✔
387
                    }}
6,700✔
388
                    @pointerenter=${this.handleSubmenuPointerenter}
6,700✔
389
                    @pointerleave=${this.handleSubmenuPointerleave}
6,700✔
390
                    @sp-menu-item-added-or-updated=${(event: Event) =>
6,700✔
391
                        event.stopPropagation()}
6,700✔
392
                >
6,700✔
393
                    ${slot}
6,700✔
394
                </sp-popover>
6,700✔
395
            </sp-overlay>
6,700✔
396
            <sp-icon-chevron100
6,700✔
397
                class="spectrum-UIIcon-ChevronRight100 chevron icon"
6,700✔
398
            ></sp-icon-chevron100>
6,700✔
399
        `;
6,700✔
400
    }
6,700✔
401

34✔
402
    protected override render(): TemplateResult {
34✔
403
        return html`
6,700✔
404
            ${this.selected
6,700✔
405
                ? html`
240✔
406
                      <sp-icon-checkmark100
240✔
407
                          id="selected"
240✔
408
                          class="spectrum-UIIcon-Checkmark100 
240✔
409
                            icon 
240✔
410
                            checkmark
240✔
411
                            ${this.hasIcon
240✔
412
                              ? 'checkmark--withAdjacentIcon'
8✔
413
                              : ''}"
240✔
414
                      ></sp-icon-checkmark100>
6,460✔
415
                  `
6,460✔
416
                : nothing}
6,700✔
417
            <slot name="icon"></slot>
6,700✔
418
            <div id="label">
6,700✔
419
                <slot id="slot"></slot>
6,700✔
420
            </div>
6,700✔
421
            <slot name="description"></slot>
6,700✔
422
            <slot name="value"></slot>
6,700✔
423
            ${this.href && this.href.length > 0
6,700✔
424
                ? super.renderAnchor({
25✔
425
                      id: 'button',
25✔
426
                      ariaHidden: true,
25✔
427
                      className: 'button anchor hidden',
25✔
428
                  })
25✔
429
                : nothing}
6,700✔
430
            ${this.renderSubmenu()}
6,700✔
431
        `;
6,700✔
432
    }
6,700✔
433

34✔
434
    /**
34✔
435
     * determines if item has a submenu and updates the `aria-haspopup` attribute
34✔
436
     */
34✔
437
    protected manageSubmenu(event: Event & { target: HTMLSlotElement }): void {
34✔
438
        this.submenuElement = event.target.assignedElements({
148✔
439
            flatten: true,
148✔
440
        })[0] as HTMLElement;
148✔
441
        this.hasSubmenu = !!this.submenuElement;
148✔
442
        if (this.hasSubmenu) {
148✔
443
            this.setAttribute('aria-haspopup', 'true');
98✔
444
        }
98✔
445
    }
148✔
446

34✔
447
    private handlePointerdown(event: PointerEvent): void {
34✔
448
        if (event.target === this && this.hasSubmenu && this.open) {
32!
449
            this.addEventListener('focus', this.handleSubmenuFocus, {
×
450
                once: true,
×
451
            });
×
452
            this.overlayElement.addEventListener(
×
453
                'beforetoggle',
×
454
                this.handleBeforetoggle
×
455
            );
×
456
        }
×
457
    }
32✔
458

34✔
459
    protected override firstUpdated(changes: PropertyValues): void {
34✔
460
        super.firstUpdated(changes);
5,703✔
461
        this.setAttribute('tabindex', '-1');
5,703✔
462
        this.addEventListener('keydown', this.handleKeydown);
5,703✔
463
        this.addEventListener('pointerdown', this.handlePointerdown);
5,703✔
464
        this.addEventListener('pointerenter', this.closeOverlaysForRoot);
5,703✔
465
        if (!this.hasAttribute('id')) {
5,703✔
466
            this.id = `sp-menu-item-${randomID()}`;
3,088✔
467
        }
3,088✔
468
    }
5,703✔
469

34✔
470
    /**
34✔
471
     * forward key info from keydown event to parent menu
34✔
472
     */
34✔
473
    handleKeydown = (event: KeyboardEvent): void => {
34✔
474
        const { target, key } = event;
132✔
475
        const openSubmenuKey =
132✔
476
            this.hasSubmenu && !this.open && [' ', 'Enter'].includes(key);
132✔
477
        if (target === this) {
132✔
478
            if (
115✔
479
                ['ArrowLeft', 'ArrowRight', 'Escape'].includes(key) ||
115✔
480
                openSubmenuKey
86✔
481
            )
115✔
482
                event.preventDefault();
115✔
483
            this.dispatchEvent(
115✔
484
                new MenuItemKeydownEvent({ root: this, event: event })
115✔
485
            );
115✔
486
        }
115✔
487
    };
132✔
488

34✔
489
    protected closeOverlaysForRoot(): void {
34✔
490
        if (this.open) return;
58✔
491
        this.menuData.parentMenu?.closeDescendentOverlays();
58✔
492
    }
58✔
493

34✔
494
    protected handleFocus(event: FocusEvent): void {
34✔
495
        const { target } = event;
462✔
496
        if (target === this) {
462✔
497
            this.focused = true;
462✔
498
        }
462✔
499
    }
462✔
500

34✔
501
    protected handleBlur(event: FocusEvent): void {
34✔
502
        const { target } = event;
1,116✔
503
        if (target === this) {
1,116✔
504
            this.focused = false;
1,116✔
505
        }
1,116✔
506
    }
1,116✔
507

34✔
508
    protected handleSubmenuClick(event: Event): void {
34✔
509
        if (event.composedPath().includes(this.overlayElement)) {
24✔
510
            return;
24✔
511
        }
24!
UNCOV
512
        this.openOverlay();
×
513
    }
24✔
514

34✔
515
    protected handleSubmenuFocus(): void {
34✔
516
        requestAnimationFrame(() => {
×
517
            // Wait till after `closeDescendentOverlays` has happened in Menu
×
518
            // to reopen (keep open) the direct descendent of this Menu Item
×
519
            this.overlayElement.open = this.open;
×
NEW
520
            this.focused = false;
×
521
        });
×
522
    }
×
523

34✔
524
    protected handleBeforetoggle = (event: Event): void => {
34✔
525
        if ((event as Event & { newState: string }).newState === 'closed') {
8✔
526
            this.open = true;
8✔
527
            this.overlayElement.manuallyKeepOpen();
8✔
528
            this.overlayElement.removeEventListener(
8✔
529
                'beforetoggle',
8✔
530
                this.handleBeforetoggle
8✔
531
            );
8✔
532
        }
8✔
533
    };
8✔
534

34✔
535
    protected handlePointerenter(): void {
34✔
536
        if (this.leaveTimeout) {
30✔
537
            clearTimeout(this.leaveTimeout);
2✔
538
            delete this.leaveTimeout;
2✔
539
            return;
2✔
540
        }
2✔
541
        this.openOverlay();
28✔
542
    }
30✔
543

34✔
544
    protected leaveTimeout?: ReturnType<typeof setTimeout>;
34✔
545
    protected recentlyLeftChild = false;
34✔
546

34✔
547
    protected handlePointerleave(): void {
34✔
548
        if (this.open && !this.recentlyLeftChild) {
6✔
549
            this.leaveTimeout = setTimeout(() => {
6✔
550
                delete this.leaveTimeout;
4✔
551
                this.open = false;
4✔
552
            }, POINTERLEAVE_TIMEOUT);
6✔
553
        }
6✔
554
    }
6✔
555

34✔
556
    /**
34✔
557
     * When there is a `change` event in the submenu for this item
34✔
558
     * then we "click" this item to cascade the selection up the
34✔
559
     * menu tree allowing all submenus between the initial selection
34✔
560
     * and the root of the tree to have their selection changes and
34✔
561
     * be closed.
34✔
562
     */
34✔
563
    protected handleSubmenuChange(event: Event): void {
34✔
564
        event.stopPropagation();
28✔
565
        this.menuData.selectionRoot?.selectOrToggleItem(this);
28!
566
    }
28✔
567

34✔
568
    protected handleSubmenuPointerenter(): void {
34✔
569
        this.recentlyLeftChild = true;
13✔
570
    }
13✔
571

34✔
572
    protected async handleSubmenuPointerleave(): Promise<void> {
34✔
573
        requestAnimationFrame(() => {
×
574
            this.recentlyLeftChild = false;
×
575
        });
×
576
    }
×
577

34✔
578
    protected handleSubmenuOpen(event: Event): void {
34✔
579
        const shouldFocus = this.matches(':focus, :focus-within') || this.focused;
34✔
580
        this.focused = false;
34✔
581
        const parentOverlay = event.composedPath().find((el) => {
34✔
582
            return (
423✔
583
                el !== this.overlayElement &&
423✔
584
                (el as HTMLElement).localName === 'sp-overlay'
423✔
585
            );
423✔
586
        }) as Overlay;
34✔
587
        if (shouldFocus)
34✔
588
            this.submenuElement?.focus();
34✔
589
        this.overlayElement.parentOverlayToForceClose = parentOverlay;
34✔
590
    }
34✔
591

34✔
592
    protected cleanup(): void {
34✔
593
        this.setAttribute('aria-expanded', 'false');
35✔
594
        this.open = false;
35✔
595
        this.active = false;
35✔
596
    }
35✔
597

34✔
598
    public async openOverlay(): Promise<void> {
34✔
599
        if (!this.hasSubmenu || this.open || this.disabled) {
39✔
600
            return;
4✔
601
        }
4✔
602
        this.open = true;
35✔
603
        this.active = true;
35✔
604
        this.setAttribute('aria-expanded', 'true');
35✔
605
        this.addEventListener('sp-closed', this.cleanup, {
35✔
606
            once: true,
35✔
607
        });
35✔
608
    }
39✔
609

34✔
610
    updateAriaSelected(): void {
34✔
611
        const role = this.getAttribute('role');
12,062✔
612
        if (role === 'option') {
12,062✔
613
            this.setAttribute(
3,978✔
614
                'aria-selected',
3,978✔
615
                this.selected ? 'true' : 'false'
3,978✔
616
            );
3,978✔
617
        } else if (role === 'menuitemcheckbox' || role === 'menuitemradio') {
12,062✔
618
            this.setAttribute('aria-checked', this.selected ? 'true' : 'false');
302✔
619
        }
302✔
620
    }
12,062✔
621

34✔
622
    public setRole(role: string): void {
34✔
623
        this.setAttribute('role', role);
6,154✔
624
        this.updateAriaSelected();
6,154✔
625
    }
6,154✔
626

34✔
627
    protected override willUpdate(changes: PropertyValues<this>): void {
34✔
628
        super.updated(changes);
6,700✔
629

6,700✔
630
        // make sure focus returns to the anchor element when submenu is closed
6,700✔
631
        if (
6,700✔
632
            changes.has('open') &&
6,700✔
633
            !this.open &&
5,773✔
634
            this.hasSubmenu &&
5,738✔
635
            this.hasVisibleFocusInTree()
35✔
636
        ) {
6,700✔
637
            this.focus();
11✔
638
        }
11✔
639
    }
6,700✔
640

34✔
641
    protected override updated(changes: PropertyValues<this>): void {
34✔
642
        super.updated(changes);
6,700✔
643
        if (
6,700✔
644
            changes.has('label') &&
6,700✔
645
            (this.label || typeof changes.get('label') !== 'undefined')
1!
646
        ) {
6,700✔
647
            this.setAttribute('aria-label', this.label || '');
1!
648
        }
1✔
649
        if (
6,700✔
650
            changes.has('active') &&
6,700✔
651
            (this.active || typeof changes.get('active') !== 'undefined')
5,773✔
652
        ) {
6,700✔
653
            if (this.active) {
70✔
654
                this.menuData.selectionRoot?.closeDescendentOverlays();
35!
655
            }
35✔
656
        }
70✔
657
        if (this.anchorElement) {
6,700✔
658
            this.anchorElement.addEventListener('focus', this.proxyFocus);
25✔
659
            this.anchorElement.tabIndex = -1;
25✔
660
        }
25✔
661
        if (changes.has('selected')) {
6,700✔
662
            this.updateAriaSelected();
5,908✔
663
        }
5,908✔
664
        if (
6,700✔
665
            changes.has('hasSubmenu') &&
6,700✔
666
            (this.hasSubmenu ||
5,802✔
667
                typeof changes.get('hasSubmenu') !== 'undefined')
5,704✔
668
        ) {
6,700✔
669
            if (this.hasSubmenu) {
99✔
670
                this.abortControllerSubmenu = new AbortController();
98✔
671
                const options = { signal: this.abortControllerSubmenu.signal };
98✔
672
                this.addEventListener(
98✔
673
                    'click',
98✔
674
                    this.handleSubmenuClick,
98✔
675
                    options
98✔
676
                );
98✔
677
                this.addEventListener(
98✔
678
                    'pointerenter',
98✔
679
                    this.handlePointerenter,
98✔
680
                    options
98✔
681
                );
98✔
682
                this.addEventListener(
98✔
683
                    'pointerleave',
98✔
684
                    this.handlePointerleave,
98✔
685
                    options
98✔
686
                );
98✔
687
                this.addEventListener(
98✔
688
                    'sp-opened',
98✔
689
                    this.handleSubmenuOpen,
98✔
690
                    options
98✔
691
                );
98✔
692
            } else {
99✔
693
                this.abortControllerSubmenu?.abort();
1!
694
            }
1✔
695
        }
99✔
696
    }
6,700✔
697

34✔
698
    public override connectedCallback(): void {
34✔
699
        super.connectedCallback();
5,735✔
700
        this.triggerUpdate();
5,735✔
701
    }
5,735✔
702

34✔
703
    _parentElement!: HTMLElement;
34✔
704

34✔
705
    public override disconnectedCallback(): void {
34✔
706
        this.menuData.cleanupSteps.forEach((removal) => removal(this));
5,735✔
707
        this.menuData = {
5,735✔
708
            focusRoot: undefined,
5,735✔
709
            parentMenu: undefined,
5,735✔
710
            selectionRoot: undefined,
5,735✔
711
            cleanupSteps: [],
5,735✔
712
        };
5,735✔
713
        super.disconnectedCallback();
5,735✔
714
    }
5,735✔
715

34✔
716
    private willDispatchUpdate = false;
34✔
717

34✔
718
    public async triggerUpdate(): Promise<void> {
34✔
719
        if (this.willDispatchUpdate) {
12,339✔
720
            return;
5,816✔
721
        }
5,816✔
722
        this.willDispatchUpdate = true;
6,523✔
723
        await new Promise((ready) => requestAnimationFrame(ready));
6,523✔
724
        this.dispatchUpdate();
6,518✔
725
    }
12,339✔
726

34✔
727
    public override focus(): void {
34✔
728
        super.focus();
228✔
729
        // ensure focus event fires in Chromium for tests
228✔
730
        this.dispatchEvent(new FocusEvent('focus'));
228✔
731
    }
228✔
732

34✔
733
    public override blur(): void {
34✔
734
        // ensure focus event fires in Chromium for tests
882✔
735
        this.dispatchEvent(new FocusEvent('blur'));
882✔
736
        super.blur();
882✔
737
    }
882✔
738

34✔
739
    public dispatchUpdate(): void {
34✔
740
        if (!this.isConnected) {
6,518✔
741
            return;
341✔
742
        }
341✔
743
        this.dispatchEvent(new MenuItemAddedOrUpdatedEvent(this));
6,177✔
744
        this.willDispatchUpdate = false;
6,177✔
745
    }
6,518✔
746

34✔
747
    public menuData: {
34✔
748
        focusRoot?: Menu;
34✔
749
        parentMenu?: Menu;
34✔
750
        selectionRoot?: Menu;
34✔
751
        cleanupSteps: ((item: MenuItem) => void)[];
34✔
752
    } = {
34✔
753
        // menu that controls ArrowUp/ArrowDown navigation
34✔
754
        focusRoot: undefined,
34✔
755
        parentMenu: undefined,
34✔
756
        // menu or menu group that controls selection
34✔
757
        selectionRoot: undefined,
34✔
758
        cleanupSteps: [],
34✔
759
    };
34✔
760
}
34✔
761

34✔
762
declare global {
34✔
763
    interface GlobalEventHandlersEventMap {
34✔
764
        'sp-menu-item-added-or-updated': MenuItemAddedOrUpdatedEvent;
34✔
765
    }
34✔
766
}
34✔
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