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

adobe / spectrum-web-components / 10697084616

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

Pull #4720

github

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

5158 of 5425 branches covered (95.08%)

Branch coverage included in aggregate %.

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

29 existing lines in 2 files now uncovered.

32481 of 32941 relevant lines covered (98.6%)

371.51 hits per line

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

94.0
/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
export class MenuItemAddedOrUpdatedEvent extends Event {
34✔
56
    constructor(item: MenuItem) {
34✔
57
        super('sp-menu-item-added-or-updated', {
4,894✔
58
            bubbles: true,
4,894✔
59
            composed: true,
4,894✔
60
        });
4,894✔
61
        this.clear(item);
4,894✔
62
    }
4,894✔
63
    clear(item: MenuItem): void {
34✔
64
        this._item = item;
4,927✔
65
        this.currentAncestorWithSelects = undefined;
4,927✔
66
        item.menuData = {
4,927✔
67
            cleanupSteps: [],
4,927✔
68
            focusRoot: undefined,
4,927✔
69
            selectionRoot: undefined,
4,927✔
70
            parentMenu: undefined,
4,927✔
71
        };
4,927✔
72
        this.menuCascade = new WeakMap<HTMLElement, MenuCascadeItem>();
4,927✔
73
    }
4,927✔
74
    menuCascade = new WeakMap<HTMLElement, MenuCascadeItem>();
34✔
75
    get item(): MenuItem {
34✔
76
        return this._item;
62,218✔
77
    }
62,218✔
78
    private _item!: MenuItem;
34✔
79
    currentAncestorWithSelects?: Menu;
34✔
80
}
34✔
81

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

34✔
84
/**
34✔
85
 * @element sp-menu-item
34✔
86
 *
34✔
87
 * @slot - text content to display within the Menu Item
34✔
88
 * @slot description - description to be placed below the label of the Menu Item
34✔
89
 * @slot icon - icon element to be placed at the start of the Menu Item
34✔
90
 * @slot value - content placed at the end of the Menu Item like values, keyboard shortcuts, etc.
34✔
91
 * @slot submenu - content placed in a submenu
34✔
92
 * @fires sp-menu-item-added - announces the item has been added so a parent menu can take ownerships
34✔
93
 */
34✔
94
export class MenuItem extends LikeAnchor(
34✔
95
    ObserveSlotText(ObserveSlotPresence(Focusable, '[slot="icon"]'))
34✔
96
) {
34✔
97
    public static override get styles(): CSSResultArray {
34✔
98
        return [menuItemStyles, checkmarkStyles, chevronStyles];
34✔
99
    }
34✔
100

34✔
101
    abortControllerSubmenu!: AbortController;
34✔
102

34✔
103
    @property({ type: Boolean, reflect: true })
34✔
104
    public active = false;
34✔
105

34✔
106
    private dependencyManager = new DependencyManagerController(this);
34✔
107

34✔
108
    @property({ type: Boolean, reflect: true })
34✔
109
    public focused = false;
34✔
110

34✔
111
    @property({ type: Boolean, reflect: true })
34✔
112
    public selected = false;
34✔
113

34✔
114
    @property({ type: String })
34✔
115
    public get value(): string {
34✔
116
        return this._value || this.itemText;
29,274✔
117
    }
29,274✔
118

34✔
119
    public set value(value: string) {
34✔
120
        if (value === this._value) {
2,582✔
121
            return;
833✔
122
        }
833✔
123
        this._value = value || '';
2,582✔
124
        if (this._value) {
2,582✔
125
            this.setAttribute('value', this._value);
1,747✔
126
        } else {
2,578✔
127
            this.removeAttribute('value');
2✔
128
        }
2✔
129
    }
2,582✔
130

34✔
131
    private _value = '';
34✔
132

34✔
133
    /**
34✔
134
     * @private
34✔
135
     */
34✔
136
    public get itemText(): string {
34✔
137
        return this.itemChildren.content.reduce(
16,038✔
138
            (acc, node) => acc + (node.textContent || '').trim(),
16,038✔
139
            ''
16,038✔
140
        );
16,038✔
141
    }
16,038✔
142

34✔
143
    @property({ type: Boolean, reflect: true, attribute: 'has-submenu' })
34✔
144
    public hasSubmenu = false;
34✔
145

34✔
146
    @query('slot:not([name])')
34✔
147
    contentSlot!: HTMLSlotElement;
34✔
148

34✔
149
    @query('slot[name="icon"]')
34✔
150
    iconSlot!: HTMLSlotElement;
34✔
151

34✔
152
    @property({
34✔
153
        type: Boolean,
34✔
154
        reflect: true,
34✔
155
        attribute: 'no-wrap',
34✔
156
        hasChanged() {
34✔
157
            return false;
4,367✔
158
        },
4,367✔
159
    })
34✔
160
    public noWrap = false;
34✔
161

34✔
162
    @query('.anchor')
34✔
163
    private anchorElement!: HTMLAnchorElement;
34✔
164

34✔
165
    @query('sp-overlay')
34✔
166
    public overlayElement!: Overlay;
34✔
167

34✔
168
    private submenuElement?: HTMLElement;
34✔
169

34✔
170
    public override get focusElement(): HTMLElement {
34✔
171
        return this;
23,606✔
172
    }
23,606✔
173

34✔
174
    protected get hasIcon(): boolean {
34✔
175
        return this.slotContentIsPresent;
330✔
176
    }
330✔
177

34✔
178
    public get itemChildren(): MenuItemChildren {
34✔
179
        if (!this.iconSlot || !this.contentSlot) {
16,321✔
180
            return {
6,988✔
181
                icon: [],
6,988✔
182
                content: [],
6,988✔
183
            };
6,988✔
184
        }
6,988✔
185
        if (this._itemChildren) {
15,945✔
186
            return this._itemChildren;
6,749✔
187
        }
6,749✔
188
        const icon = this.iconSlot.assignedElements().map((element) => {
2,584✔
189
            const newElement = element.cloneNode(true) as HTMLElement;
4✔
190
            newElement.removeAttribute('slot');
4✔
191
            newElement.classList.toggle('icon');
4✔
192
            return newElement;
4✔
193
        });
2,584✔
194
        const content = this.contentSlot
2,584✔
195
            .assignedNodes()
2,584✔
196
            .map((node) => node.cloneNode(true));
2,584✔
197
        this._itemChildren = { icon, content };
2,584✔
198

2,584✔
199
        return this._itemChildren;
2,584✔
200
    }
16,321✔
201

34✔
202
    private _itemChildren?: MenuItemChildren;
34✔
203

34✔
204
    constructor() {
34✔
205
        super();
4,367✔
206
        this.addEventListener('click', this.handleClickCapture, {
4,367✔
207
            capture: true,
4,367✔
208
        });
4,367✔
209

4,367✔
210
        new MutationController(this, {
4,367✔
211
            config: {
4,367✔
212
                characterData: true,
4,367✔
213
                childList: true,
4,367✔
214
                subtree: true,
4,367✔
215
            },
4,367✔
216
            callback: (mutations) => {
4,367✔
217
                const isSubmenu = mutations.every(
4,421✔
218
                    (mutation) =>
4,421✔
219
                        (mutation.target as HTMLElement).slot === 'submenu'
22✔
220
                );
4,421✔
221
                if (isSubmenu) {
4,421✔
222
                    return;
4,399✔
223
                }
4,399✔
224
                this.breakItemChildrenCache();
22✔
225
            },
4,421✔
226
        });
4,367✔
227
    }
4,367✔
228

34✔
229
    @property({ type: Boolean, reflect: true })
34✔
230
    public open = false;
34✔
231

34✔
232
    public override click(): void {
34✔
233
        if (this.disabled) {
145✔
234
            return;
1✔
235
        }
1✔
236

144✔
237
        if (this.shouldProxyClick()) {
144✔
238
            return;
3✔
239
        }
3✔
240

141✔
241
        super.click();
141✔
242
    }
145✔
243

34✔
244
    private handleClickCapture(event: Event): void | boolean {
34✔
245
        if (this.disabled) {
155✔
246
            event.preventDefault();
1✔
247
            event.stopImmediatePropagation();
1✔
248
            event.stopPropagation();
1✔
249
            return false;
1✔
250
        }
1✔
251
    }
155✔
252

34✔
253
    private handleSlottableRequest = (event: SlottableRequestEvent): void => {
34✔
254
        this.submenuElement?.dispatchEvent(
18!
255
            new SlottableRequestEvent(event.name, event.data)
18✔
256
        );
18✔
257
    };
18✔
258

34✔
259
    private proxyFocus = (): void => {
34✔
260
        this.focus();
9✔
261
    };
9✔
262

34✔
263
    private shouldProxyClick(): boolean {
34✔
264
        let handled = false;
144✔
265
        if (this.anchorElement) {
144✔
266
            this.anchorElement.click();
3✔
267
            handled = true;
3✔
268
        }
3✔
269
        return handled;
144✔
270
    }
144✔
271

34✔
272
    protected breakItemChildrenCache(): void {
34✔
273
        this._itemChildren = undefined;
22✔
274
        this.triggerUpdate();
22✔
275
    }
22✔
276

34✔
277
    protected renderSubmenu(): TemplateResult {
34✔
278
        const slot = html`
5,083✔
279
            <slot
5,083✔
280
                name="submenu"
5,083✔
281
                @slotchange=${this.manageSubmenu}
5,083✔
282
                @sp-menu-item-added-or-updated=${{
5,083✔
283
                    handleEvent: (event: MenuItemAddedOrUpdatedEvent) => {
5,083✔
284
                        event.clear(event.item);
33✔
285
                    },
33✔
286
                    capture: true,
5,083✔
287
                }}
5,083✔
288
                @focusin=${(event: Event) => event.stopPropagation()}
5,083✔
289
            ></slot>
5,083✔
290
        `;
5,083✔
291
        if (!this.hasSubmenu) {
5,083✔
292
            return slot;
5,038✔
293
        }
5,038✔
294
        this.dependencyManager.add('sp-overlay');
45✔
295
        this.dependencyManager.add('sp-popover');
45✔
296
        import('@spectrum-web-components/overlay/sp-overlay.js');
45✔
297
        import('@spectrum-web-components/popover/sp-popover.js');
45✔
298
        return html`
45✔
299
            <sp-overlay
45✔
300
                .triggerElement=${this as HTMLElement}
45✔
301
                ?disabled=${!this.hasSubmenu}
45✔
302
                ?open=${this.hasSubmenu &&
45✔
303
                this.open &&
45✔
304
                this.dependencyManager.loaded}
5,083✔
305
                .placement=${this.isLTR ? 'right-start' : 'left-start'}
5,083✔
306
                .offset=${[-10, -5] as [number, number]}
5,083✔
307
                .type=${'auto'}
5,083✔
308
                @close=${(event: Event) => event.stopPropagation()}
5,083✔
309
                @slottable-request=${this.handleSlottableRequest}
5,083✔
310
            >
5,083✔
311
                <sp-popover
5,083✔
312
                    @change=${(event: Event) => {
5,083✔
313
                        this.handleSubmenuChange(event);
1✔
314
                        this.open = false;
1✔
315
                    }}
5,083✔
316
                    @pointerenter=${this.handleSubmenuPointerenter}
5,083✔
317
                    @pointerleave=${this.handleSubmenuPointerleave}
5,083✔
318
                    @sp-menu-item-added-or-updated=${(event: Event) =>
5,083✔
319
                        event.stopPropagation()}
5,083✔
320
                >
5,083✔
321
                    ${slot}
5,083✔
322
                </sp-popover>
5,083✔
323
            </sp-overlay>
5,083✔
324
            <sp-icon-chevron100
5,083✔
325
                class="spectrum-UIIcon-ChevronRight100 chevron icon"
5,083✔
326
            ></sp-icon-chevron100>
5,083✔
327
        `;
5,083✔
328
    }
5,083✔
329

34✔
330
    protected override render(): TemplateResult {
34✔
331
        return html`
5,083✔
332
            ${this.selected
5,083✔
333
                ? html`
330✔
334
                      <sp-icon-checkmark100
330✔
335
                          id="selected"
330✔
336
                          class="spectrum-UIIcon-Checkmark100 
330✔
337
                            icon 
330✔
338
                            checkmark
330✔
339
                            ${this.hasIcon
330✔
340
                              ? 'checkmark--withAdjacentIcon'
4✔
341
                              : ''}"
330✔
342
                      ></sp-icon-checkmark100>
4,753✔
343
                  `
4,753✔
344
                : nothing}
5,083✔
345
            <slot name="icon"></slot>
5,083✔
346
            <div id="label">
5,083✔
347
                <slot id="slot"></slot>
5,083✔
348
            </div>
5,083✔
349
            <slot name="description"></slot>
5,083✔
350
            <slot name="value"></slot>
5,083✔
351
            ${this.href && this.href.length > 0
5,083✔
352
                ? super.renderAnchor({
18✔
353
                      id: 'button',
18✔
354
                      ariaHidden: true,
18✔
355
                      className: 'button anchor hidden',
18✔
356
                  })
18✔
357
                : nothing}
5,083✔
358
            ${this.renderSubmenu()}
5,083✔
359
        `;
5,083✔
360
    }
5,083✔
361

34✔
362
    protected manageSubmenu(event: Event & { target: HTMLSlotElement }): void {
34✔
363
        this.submenuElement = event.target.assignedElements({
40✔
364
            flatten: true,
40✔
365
        })[0] as HTMLElement;
40✔
366
        this.hasSubmenu = !!this.submenuElement;
40✔
367
        if (this.hasSubmenu) {
40✔
368
            this.setAttribute('aria-haspopup', 'true');
26✔
369
        }
26✔
370
    }
40✔
371

34✔
372
    private handlePointerdown(event: PointerEvent): void {
34✔
373
        if (event.target === this && this.hasSubmenu && this.open) {
11!
374
            this.addEventListener('focus', this.handleSubmenuFocus, {
×
375
                once: true,
×
376
            });
×
377
            this.overlayElement.addEventListener(
×
378
                'beforetoggle',
×
379
                this.handleBeforetoggle
×
380
            );
×
381
        }
×
382
    }
11✔
383

34✔
384
    protected override firstUpdated(changes: PropertyValues): void {
34✔
385
        super.firstUpdated(changes);
4,367✔
386
        this.setAttribute('tabindex', '-1');
4,367✔
387
        this.addEventListener('pointerdown', this.handlePointerdown);
4,367✔
388
        this.addEventListener('pointerenter', this.closeOverlaysForRoot);
4,367✔
389
        if (!this.hasAttribute('id')) {
4,367✔
390
            this.id = `sp-menu-item-${randomID()}`;
3,007✔
391
        }
3,007✔
392
    }
4,367✔
393

34✔
394
    protected closeOverlaysForRoot(): void {
34✔
395
        if (this.open) return;
21!
396
        this.menuData.parentMenu?.closeDescendentOverlays();
21!
397
    }
21✔
398

34✔
399
    protected handleSubmenuClick(event: Event): void {
34✔
400
        if (event.composedPath().includes(this.overlayElement)) {
2✔
401
            return;
1✔
402
        }
1✔
403
        this.openOverlay();
1✔
404
    }
2✔
405

34✔
406
    protected handleSubmenuFocus(): void {
34✔
407
        requestAnimationFrame(() => {
×
408
            // Wait till after `closeDescendentOverlays` has happened in Menu
×
409
            // to reopen (keep open) the direct descendent of this Menu Item
×
410
            this.overlayElement.open = this.open;
×
411
        });
×
412
    }
×
413

34✔
414
    protected handleBeforetoggle = (event: Event): void => {
34✔
415
        if ((event as Event & { newState: string }).newState === 'closed') {
8✔
416
            this.open = true;
8✔
417
            this.overlayElement.manuallyKeepOpen();
8✔
418
            this.overlayElement.removeEventListener(
8✔
419
                'beforetoggle',
8✔
420
                this.handleBeforetoggle
8✔
421
            );
8✔
422
        }
8✔
423
    };
8✔
424

34✔
425
    protected handlePointerenter(): void {
34✔
426
        if (this.leaveTimeout) {
5!
UNCOV
427
            clearTimeout(this.leaveTimeout);
×
UNCOV
428
            delete this.leaveTimeout;
×
UNCOV
429
            return;
×
UNCOV
430
        }
×
431
        this.openOverlay();
5✔
432
    }
5✔
433

34✔
434
    protected leaveTimeout?: ReturnType<typeof setTimeout>;
34✔
435
    protected recentlyLeftChild = false;
34✔
436

34✔
437
    protected handlePointerleave(): void {
34✔
UNCOV
438
        if (this.open && !this.recentlyLeftChild) {
×
UNCOV
439
            this.leaveTimeout = setTimeout(() => {
×
UNCOV
440
                delete this.leaveTimeout;
×
UNCOV
441
                this.open = false;
×
UNCOV
442
            }, POINTERLEAVE_TIMEOUT);
×
UNCOV
443
        }
×
UNCOV
444
    }
×
445

34✔
446
    /**
34✔
447
     * When there is a `change` event in the submenu for this item
34✔
448
     * then we "click" this item to cascade the selection up the
34✔
449
     * menu tree allowing all submenus between the initial selection
34✔
450
     * and the root of the tree to have their selection changes and
34✔
451
     * be closed.
34✔
452
     */
34✔
453
    protected handleSubmenuChange(event: Event): void {
34✔
454
        event.stopPropagation();
1✔
455
        this.menuData.selectionRoot?.selectOrToggleItem(this);
1!
456
    }
1✔
457

34✔
458
    protected handleSubmenuPointerenter(): void {
34✔
UNCOV
459
        this.recentlyLeftChild = true;
×
UNCOV
460
    }
×
461

34✔
462
    protected async handleSubmenuPointerleave(): Promise<void> {
34✔
463
        requestAnimationFrame(() => {
×
464
            this.recentlyLeftChild = false;
×
465
        });
×
466
    }
×
467

34✔
468
    protected handleSubmenuOpen(event: Event): void {
34✔
469
        this.focused = false;
2✔
470
        const parentOverlay = event.composedPath().find((el) => {
2✔
471
            return (
24✔
472
                el !== this.overlayElement &&
24✔
473
                (el as HTMLElement).localName === 'sp-overlay'
24✔
474
            );
24✔
475
        }) as Overlay;
2✔
476
        this.overlayElement.parentOverlayToForceClose = parentOverlay;
2✔
477
    }
2✔
478

34✔
479
    protected cleanup(): void {
34✔
480
        this.open = false;
4✔
481
        this.active = false;
4✔
482
    }
4✔
483

34✔
484
    public async openOverlay(): Promise<void> {
34✔
485
        if (!this.hasSubmenu || this.open || this.disabled) {
6!
UNCOV
486
            return;
×
UNCOV
487
        }
×
488
        this.open = true;
6✔
489
        this.active = true;
6✔
490
        this.setAttribute('aria-expanded', 'true');
6✔
491
        this.addEventListener('sp-closed', this.cleanup, {
6✔
492
            once: true,
6✔
493
        });
6✔
494
    }
6✔
495

34✔
496
    updateAriaSelected(): void {
34✔
497
        const role = this.getAttribute('role');
9,579✔
498
        if (role === 'option') {
9,579✔
499
            this.setAttribute(
2,693✔
500
                'aria-selected',
2,693✔
501
                this.selected ? 'true' : 'false'
2,693✔
502
            );
2,693✔
503
        } else if (role === 'menuitemcheckbox' || role === 'menuitemradio') {
9,579✔
504
            this.setAttribute('aria-checked', this.selected ? 'true' : 'false');
452✔
505
        }
452✔
506
    }
9,579✔
507

34✔
508
    public setRole(role: string): void {
34✔
509
        this.setAttribute('role', role);
4,885✔
510
        this.updateAriaSelected();
4,885✔
511
    }
4,885✔
512

34✔
513
    protected override updated(changes: PropertyValues<this>): void {
34✔
514
        super.updated(changes);
5,083✔
515
        if (
5,083✔
516
            changes.has('label') &&
5,083✔
517
            (this.label || typeof changes.get('label') !== 'undefined')
1!
518
        ) {
5,083✔
519
            this.setAttribute('aria-label', this.label || '');
1!
520
        }
1✔
521
        if (
5,083✔
522
            changes.has('active') &&
5,083✔
523
            (this.active || typeof changes.get('active') !== 'undefined')
4,377✔
524
        ) {
5,083✔
525
            if (this.active) {
10✔
526
                this.menuData.selectionRoot?.closeDescendentOverlays();
6!
527
            }
6✔
528
        }
10✔
529
        if (this.anchorElement) {
5,083✔
530
            this.anchorElement.addEventListener('focus', this.proxyFocus);
18✔
531
            this.anchorElement.tabIndex = -1;
18✔
532
        }
18✔
533
        if (changes.has('selected')) {
5,083✔
534
            this.updateAriaSelected();
4,694✔
535
        }
4,694✔
536
        if (
5,083✔
537
            changes.has('hasSubmenu') &&
5,083✔
538
            (this.hasSubmenu ||
4,394✔
539
                typeof changes.get('hasSubmenu') !== 'undefined')
4,368✔
540
        ) {
5,083✔
541
            if (this.hasSubmenu) {
27✔
542
                this.abortControllerSubmenu = new AbortController();
26✔
543
                const options = { signal: this.abortControllerSubmenu.signal };
26✔
544
                this.addEventListener(
26✔
545
                    'click',
26✔
546
                    this.handleSubmenuClick,
26✔
547
                    options
26✔
548
                );
26✔
549
                this.addEventListener(
26✔
550
                    'pointerenter',
26✔
551
                    this.handlePointerenter,
26✔
552
                    options
26✔
553
                );
26✔
554
                this.addEventListener(
26✔
555
                    'pointerleave',
26✔
556
                    this.handlePointerleave,
26✔
557
                    options
26✔
558
                );
26✔
559
                this.addEventListener(
26✔
560
                    'sp-opened',
26✔
561
                    this.handleSubmenuOpen,
26✔
562
                    options
26✔
563
                );
26✔
564
            } else {
27✔
565
                this.abortControllerSubmenu?.abort();
1!
566
            }
1✔
567
        }
27✔
568
    }
5,083✔
569

34✔
570
    public override connectedCallback(): void {
34✔
571
        super.connectedCallback();
4,399✔
572
        this.triggerUpdate();
4,399✔
573
    }
4,399✔
574

34✔
575
    _parentElement!: HTMLElement;
34✔
576

34✔
577
    public override disconnectedCallback(): void {
34✔
578
        this.menuData.cleanupSteps.forEach((removal) => removal(this));
4,399✔
579
        this.menuData = {
4,399✔
580
            focusRoot: undefined,
4,399✔
581
            parentMenu: undefined,
4,399✔
582
            selectionRoot: undefined,
4,399✔
583
            cleanupSteps: [],
4,399✔
584
        };
4,399✔
585
        super.disconnectedCallback();
4,399✔
586
    }
4,399✔
587

34✔
588
    private willDispatchUpdate = false;
34✔
589

34✔
590
    public async triggerUpdate(): Promise<void> {
34✔
591
        if (this.willDispatchUpdate) {
9,780✔
592
            return;
4,584✔
593
        }
4,584✔
594
        this.willDispatchUpdate = true;
5,196✔
595
        await new Promise((ready) => requestAnimationFrame(ready));
5,196✔
596
        this.dispatchUpdate();
5,196✔
597
    }
9,780✔
598

34✔
599
    public dispatchUpdate(): void {
34✔
600
        if (!this.isConnected) {
5,196✔
601
            return;
302✔
602
        }
302✔
603
        this.dispatchEvent(new MenuItemAddedOrUpdatedEvent(this));
4,894✔
604
        this.willDispatchUpdate = false;
4,894✔
605
    }
5,196✔
606

34✔
607
    public menuData: {
34✔
608
        focusRoot?: Menu;
34✔
609
        parentMenu?: Menu;
34✔
610
        selectionRoot?: Menu;
34✔
611
        cleanupSteps: ((item: MenuItem) => void)[];
34✔
612
    } = {
34✔
613
        focusRoot: undefined,
34✔
614
        parentMenu: undefined,
34✔
615
        selectionRoot: undefined,
34✔
616
        cleanupSteps: [],
34✔
617
    };
34✔
618
}
34✔
619

34✔
620
declare global {
34✔
621
    interface GlobalEventHandlersEventMap {
34✔
622
        'sp-menu-item-added-or-updated': MenuItemAddedOrUpdatedEvent;
34✔
623
    }
34✔
624
}
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