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

adobe / spectrum-web-components / 13553164764

26 Feb 2025 08:53PM UTC 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

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

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

3✔
13
import {
3✔
14
    CSSResultArray,
3✔
15
    html,
3✔
16
    nothing,
3✔
17
    PropertyValues,
3✔
18
    type SpectrumElement,
3✔
19
    TemplateResult,
3✔
20
} from '@spectrum-web-components/base';
3✔
21
import {
3✔
22
    property,
3✔
23
    query,
3✔
24
    state,
3✔
25
} from '@spectrum-web-components/base/src/decorators.js';
3✔
26
import {
3✔
27
    ifDefined,
3✔
28
    live,
3✔
29
    repeat,
3✔
30
} from '@spectrum-web-components/base/src/directives.js';
3✔
31
import '@spectrum-web-components/overlay/sp-overlay.js';
3✔
32
import '@spectrum-web-components/icons-ui/icons/sp-icon-chevron100.js';
3✔
33
import '@spectrum-web-components/popover/sp-popover.js';
3✔
34
import '@spectrum-web-components/menu/sp-menu.js';
3✔
35
import '@spectrum-web-components/menu/sp-menu-item.js';
3✔
36
import { PendingStateController } from '@spectrum-web-components/reactive-controllers/src/PendingState.js';
3✔
37
import '@spectrum-web-components/picker-button/sp-picker-button.js';
3✔
38
import { Textfield } from '@spectrum-web-components/textfield';
3✔
39
import type { Tooltip } from '@spectrum-web-components/tooltip';
3✔
40

3✔
41
import styles from './combobox.css.js';
3✔
42
import chevronStyles from '@spectrum-web-components/icon/src/spectrum-icon-chevron.css.js';
3✔
43
import { Menu, MenuItem } from '@spectrum-web-components/menu';
3✔
44

3✔
45
export type ComboboxOption = {
3✔
46
    value: string;
3✔
47
    itemText: string;
3✔
48
    disabled?: boolean;
3✔
49
};
3✔
50

3✔
51
/**
3✔
52
 * @element sp-combobox
3✔
53
 * @slot - Supply Menu Item elements to the default slot in order to populate the available options
3✔
54
 * @slot tooltip - Tooltip to to be applied to the the Picker Button
3✔
55
 */
3✔
56
export class Combobox extends Textfield {
3✔
57
    public static override get styles(): CSSResultArray {
3✔
58
        return [...super.styles, styles, chevronStyles];
3✔
59
    }
3✔
60

3✔
61
    /**
3✔
62
     * The currently active ComboboxItem descendant, when available.
3✔
63
     */
3✔
64
    @state()
3✔
65
    private activeDescendant?: ComboboxOption | MenuItem;
3✔
66

3✔
67
    @property({ type: String })
3✔
68
    public override autocomplete: 'list' | 'none' = 'none';
3✔
69

3✔
70
    @state()
3✔
71
    private availableOptions: (ComboboxOption | MenuItem)[] = [];
3✔
72

3✔
73
    /**
3✔
74
     * Whether the listbox is visible.
3✔
75
     **/
3✔
76
    @property({ type: Boolean, reflect: true })
3✔
77
    public open = false;
3✔
78

3✔
79
    /** Whether the items are currently loading. */
3✔
80
    @property({ type: Boolean, reflect: true })
3✔
81
    public pending = false;
3✔
82

3✔
83
    /** Defines a string value that labels the Combobox while it is in pending state. */
3✔
84
    @property({ type: String, attribute: 'pending-label' })
3✔
85
    public pendingLabel = 'Pending';
3✔
86

3✔
87
    public pendingStateController: PendingStateController<this>;
3✔
88

3✔
89
    /**
3✔
90
     * Initializes the `PendingStateController` for the Combobox component.
3✔
91
     * When the pending state changes to `true`, the `open` property of the Combobox is set to `false`.
3✔
92
     */
3✔
93
    constructor() {
3✔
94
        super();
67✔
95
        this.pendingStateController = new PendingStateController(this);
67✔
96
    }
67✔
97

3✔
98
    @query('slot:not([name])')
3✔
99
    private optionSlot!: HTMLSlotElement;
3✔
100

3✔
101
    @state()
3✔
102
    overlayOpen = false;
3✔
103

3✔
104
    @query('#input')
3✔
105
    private input!: HTMLInputElement;
3✔
106

3✔
107
    private itemValue = '';
3✔
108

3✔
109
    /**
3✔
110
     * An array of options to present in the Menu provided while typing into the input
3✔
111
     */
3✔
112
    @property({ type: Array })
3✔
113
    public options?: (ComboboxOption | MenuItem)[];
3✔
114

3✔
115
    /**
3✔
116
     * The array of the children of the combobox, ie ComboboxItems.
3✔
117
     **/
3✔
118
    @state()
3✔
119
    protected optionEls: MenuItem[] = [];
3✔
120

3✔
121
    private tooltipEl?: Tooltip;
3✔
122

3✔
123
    public override focus(): void {
3✔
124
        this.focusElement.focus();
69✔
125
    }
69✔
126

3✔
127
    public override click(): void {
3✔
128
        this.focus();
12✔
129
        this.focusElement.click();
12✔
130
    }
12✔
131

3✔
132
    private scrollToActiveDescendant(): void {
3✔
133
        if (!this.activeDescendant) {
33!
134
            return;
×
135
        }
×
136
        const activeEl = this.shadowRoot.getElementById(
33✔
137
            this.activeDescendant.value
33✔
138
        );
33✔
139
        if (activeEl) {
33✔
140
            activeEl.scrollIntoView({ block: 'nearest' });
20✔
141
        }
20✔
142
    }
33✔
143

3✔
144
    public handleComboboxKeydown(event: KeyboardEvent): void {
3✔
145
        if (this.readonly || this.pending) {
56✔
146
            return;
2✔
147
        }
2✔
148
        if (event.altKey && event.code === 'ArrowDown') {
56✔
149
            this.open = true;
1✔
150
        } else if (event.code === 'ArrowDown') {
56✔
151
            event.preventDefault();
24✔
152
            this.open = true;
24✔
153
            this.activateNextDescendant();
24✔
154
            this.scrollToActiveDescendant();
24✔
155
        } else if (event.code === 'ArrowUp') {
52✔
156
            event.preventDefault();
9✔
157
            this.open = true;
9✔
158
            this.activatePreviousDescendant();
9✔
159
            this.scrollToActiveDescendant();
9✔
160
        } else if (event.code === 'Escape') {
29✔
161
            if (!this.open) {
2✔
162
                this.value = '';
1✔
163
            }
1✔
164
            this.open = false;
2✔
165
        } else if (event.code === 'Enter') {
20✔
166
            this.selectDescendant();
3✔
167
            this.open = false;
3✔
168
        } else if (event.code === 'Home') {
18✔
169
            this.focusElement.setSelectionRange(0, 0);
1✔
170
            this.activeDescendant = undefined;
1✔
171
        } else if (event.code === 'End') {
15✔
172
            const { length } = this.value;
1✔
173
            this.focusElement.setSelectionRange(length, length);
1✔
174
            this.activeDescendant = undefined;
1✔
175
        } else if (event.code === 'ArrowLeft') {
14✔
176
            this.activeDescendant = undefined;
3✔
177
        } else if (event.code === 'ArrowRight') {
13✔
178
            this.activeDescendant = undefined;
1✔
179
        }
1✔
180
    }
56✔
181

3✔
182
    /**
3✔
183
     * Convert the flattened array of assigned elements of `slot[name='option']` to
3✔
184
     * an array of `ComboboxOptions` for use in rendering options in the shadow DOM.s
3✔
185
     **/
3✔
186
    public handleSlotchange(): void {
3✔
187
        this.setOptionsFromSlottedItems();
68✔
188
        this.itemObserver.disconnect();
68✔
189
        this.optionEls.map((item) => {
68✔
190
            this.itemObserver.observe(item, {
620✔
191
                attributes: true,
620✔
192
                attributeFilter: ['id'],
620✔
193
                childList: true,
620✔
194
            });
620✔
195
        });
68✔
196
    }
68✔
197

3✔
198
    protected handleTooltipSlotchange(
3✔
199
        event: Event & { target: HTMLSlotElement }
2✔
200
    ): void {
2✔
201
        this.tooltipEl = event.target.assignedElements()[0] as
2✔
202
            | Tooltip
2✔
203
            | undefined;
2✔
204
    }
2✔
205

3✔
206
    public setOptionsFromSlottedItems(): void {
3✔
207
        const elements = this.optionSlot.assignedElements({
73✔
208
            flatten: true,
73✔
209
        }) as MenuItem[];
73✔
210
        // Element data
73✔
211
        this.optionEls = elements;
73✔
212
    }
73✔
213

3✔
214
    public activateNextDescendant(): void {
3✔
215
        const activeIndex = !this.activeDescendant
24✔
216
            ? -1
16✔
217
            : this.availableOptions.indexOf(this.activeDescendant);
8✔
218
        let nextActiveIndex = activeIndex;
24✔
219
        do {
24✔
220
            nextActiveIndex =
25✔
221
                (this.availableOptions.length + nextActiveIndex + 1) %
25✔
222
                this.availableOptions.length;
25✔
223
            // Break if we've checked all options to avoid infinite loop
25✔
224
            if (nextActiveIndex === activeIndex) break;
25✔
225
        } while (this.availableOptions[nextActiveIndex].disabled);
24✔
226

24✔
227
        if (!this.availableOptions[nextActiveIndex].disabled) {
24✔
228
            this.activeDescendant = this.availableOptions[nextActiveIndex];
24✔
229
        }
24✔
230
        this.optionEls.forEach((el) =>
24✔
231
            el.setAttribute(
249✔
232
                'aria-selected',
249✔
233
                el.value === this.activeDescendant?.value ? 'true' : 'false'
249✔
234
            )
249✔
235
        );
24✔
236
    }
24✔
237

3✔
238
    public activatePreviousDescendant(): void {
3✔
239
        const activeIndex = !this.activeDescendant
9✔
240
            ? 0
4✔
241
            : this.availableOptions.indexOf(this.activeDescendant);
5✔
242
        let previousActiveIndex = activeIndex;
9✔
243
        do {
9✔
244
            previousActiveIndex =
9✔
245
                (this.availableOptions.length + previousActiveIndex - 1) %
9✔
246
                this.availableOptions.length;
9✔
247
            // Break if we've checked all options to avoid infinite loop
9✔
248
            if (previousActiveIndex === activeIndex) break;
9!
249
        } while (this.availableOptions[previousActiveIndex].disabled);
9✔
250

9✔
251
        if (!this.availableOptions[previousActiveIndex].disabled) {
9✔
252
            this.activeDescendant = this.availableOptions[previousActiveIndex];
9✔
253
        }
9✔
254
        this.optionEls.forEach((el) =>
9✔
NEW
255
            el.setAttribute(
×
NEW
256
                'aria-selected',
×
NEW
257
                el.value === this.activeDescendant?.value ? 'true' : 'false'
×
NEW
258
            )
×
259
        );
9✔
260
    }
9✔
261

3✔
262
    public selectDescendant(): void {
3✔
263
        if (!this.activeDescendant) {
3✔
264
            return;
1✔
265
        }
1✔
266

2✔
267
        const activeEl = this.shadowRoot.getElementById(
2✔
268
            this.activeDescendant.value
2✔
269
        );
2✔
270
        if (activeEl) {
2✔
271
            activeEl.click();
2✔
272
        }
2✔
273
    }
3✔
274

3✔
275
    public filterAvailableOptions(): void {
3✔
276
        if (this.autocomplete === 'none' || this.pending) {
93✔
277
            return;
20✔
278
        }
20✔
279
        const valueLowerCase = this.value.toLowerCase();
73✔
280
        this.availableOptions = (this.options || this.optionEls).filter(
93!
281
            (descendant) => {
93✔
282
                const itemTextLowerCase = descendant.itemText.toLowerCase();
2,036✔
283
                return itemTextLowerCase.startsWith(valueLowerCase);
2,036✔
284
            }
2,036✔
285
        );
93✔
286
    }
93✔
287

3✔
288
    public override handleInput(event: Event): void {
3✔
289
        super.handleInput(event);
8✔
290
        if (!this.pending) {
8✔
291
            this.activeDescendant = undefined;
7✔
292
            this.open = true;
7✔
293
        }
7✔
294
    }
8✔
295

3✔
296
    protected handleMenuChange(event: PointerEvent & { target: Menu }): void {
3✔
297
        const { target } = event;
5✔
298
        const selected = (this.options || this.optionEls).find(
5✔
299
            (item) => item.value === target?.value
5✔
300
        );
5✔
301
        this.value = selected?.itemText || '';
5!
302
        event.preventDefault();
5✔
303
        this.open = false;
5✔
304
        this._returnItems();
5✔
305
        this.focus();
5✔
306
    }
5✔
307

3✔
308
    public handleClosed(): void {
3✔
309
        this.open = false;
41✔
310
        this.overlayOpen = false;
41✔
311
    }
41✔
312

3✔
313
    public handleOpened(): void {
3✔
314
        // Do stuff here?
27✔
315
    }
27✔
316

3✔
317
    public toggleOpen(): void {
3✔
318
        if (this.readonly || this.pending) {
22✔
319
            this.open = false;
1✔
320
            return;
1✔
321
        }
1✔
322
        this.open = !this.open;
21✔
323
        this.inputElement.focus();
21✔
324
    }
22✔
325

3✔
326
    protected override shouldUpdate(
3✔
327
        changed: PropertyValues<this & { optionEls: MenuItem[] }>
373✔
328
    ): boolean {
373✔
329
        if (changed.has('open')) {
373✔
330
            if (!this.open) {
156✔
331
                this.activeDescendant = undefined;
112✔
332
            } else {
156✔
333
                this.overlayOpen = true;
44✔
334
            }
44✔
335
        }
156✔
336
        if (changed.has('value')) {
373✔
337
            this.filterAvailableOptions();
93✔
338
            this.itemValue =
93✔
339
                this.availableOptions.find(
93✔
340
                    (option) => option.itemText === this.value
93✔
341
                )?.value ?? '';
79✔
342
        }
93✔
343
        return super.shouldUpdate(changed);
373✔
344
    }
373✔
345

3✔
346
    protected override onBlur(event: FocusEvent): void {
3✔
347
        if (
53✔
348
            event.relatedTarget &&
53✔
349
            (this.contains(event.relatedTarget as HTMLElement) ||
5✔
350
                this.shadowRoot.contains(event.relatedTarget as HTMLElement))
5✔
351
        ) {
53✔
352
            return;
4✔
353
        }
4✔
354
        super.onBlur(event);
49✔
355
    }
53✔
356

3✔
357
    protected renderAppliedLabel(): TemplateResult {
3✔
358
        /**
373✔
359
         * appliedLabel corresponds to `<label for="...">`, which is overriden
373✔
360
         * if user adds the `label` attribute manually to `<sp-combobox>`.
373✔
361
         **/
373✔
362
        const appliedLabel = this.label || this.appliedLabel;
373✔
363

373✔
364
        return html`
373✔
365
            ${this.pending
373✔
366
                ? html`
12✔
367
                      <span
12✔
368
                          aria-hidden="true"
12✔
369
                          class="visually-hidden"
12✔
370
                          id="pending-label"
12✔
371
                      >
12✔
372
                          ${this.pendingLabel}
12✔
373
                      </span>
361✔
374
                  `
361✔
375
                : nothing}
373✔
376
            ${this.value
373✔
377
                ? html`
75✔
378
                      <span
75✔
379
                          aria-hidden="true"
75✔
380
                          class="visually-hidden"
75✔
381
                          id="applied-label"
75✔
382
                      >
75✔
383
                          ${appliedLabel}
75✔
384
                      </span>
75✔
385
                      <slot name="label" id="label">
75✔
386
                          <span class="visually-hidden" aria-hidden="true">
75✔
387
                              ${this.value}
75✔
388
                          </span>
298✔
389
                      </slot>
298✔
390
                  `
298✔
391
                : html`
298✔
392
                      <span hidden id="applied-label">${appliedLabel}</span>
298✔
393
                  `}
373✔
394
        `;
373✔
395
    }
373✔
396

3✔
397
    protected renderLoader(): TemplateResult {
3✔
398
        import(
×
399
            '@spectrum-web-components/progress-circle/sp-progress-circle.js'
×
400
        );
×
401
        return html`
×
402
            <sp-progress-circle
×
403
                size="s"
×
404
                indeterminate
×
405
                aria-hidden="true"
×
406
                class="progress-circle"
×
407
            ></sp-progress-circle>
×
408
        `;
×
409
    }
×
410

3✔
411
    protected override renderField(): TemplateResult {
3✔
412
        return html`
373✔
413
            ${this.renderStateIcons()}
373✔
414
            <input
373✔
415
                aria-activedescendant=${ifDefined(
373✔
416
                    this.activeDescendant
373✔
417
                        ? `${this.activeDescendant.value}`
33✔
418
                        : undefined
340✔
419
                )}
373✔
420
                aria-autocomplete=${ifDefined(
373✔
421
                    this.autocomplete as 'list' | 'none'
373✔
422
                )}
373✔
423
                aria-controls=${ifDefined(
373✔
424
                    this.open ? 'listbox-menu' : undefined
373✔
425
                )}
373✔
426
                aria-describedby="${this.helpTextId} tooltip"
373✔
427
                aria-expanded="${this.open ? 'true' : 'false'}"
373✔
428
                aria-label=${ifDefined(this.label || this.appliedLabel)}
373✔
429
                aria-labelledby="pending-label applied-label label"
373✔
430
                aria-invalid=${ifDefined(this.invalid || undefined)}
373✔
431
                autocomplete="off"
373✔
432
                @click=${this.toggleOpen}
373✔
433
                @keydown=${this.handleComboboxKeydown}
373✔
434
                id="input"
373✔
435
                class="input"
373✔
436
                role="combobox"
373✔
437
                type="text"
373✔
438
                .value=${live(this.displayValue)}
373✔
439
                tabindex="0"
373✔
440
                @sp-closed=${this.handleClosed}
373✔
441
                @sp-opened=${this.handleOpened}
373✔
442
                maxlength=${ifDefined(
373✔
443
                    this.maxlength > -1 ? this.maxlength : undefined
373!
444
                )}
373✔
445
                minlength=${ifDefined(
373✔
446
                    this.minlength > -1 ? this.minlength : undefined
373!
447
                )}
373✔
448
                pattern=${ifDefined(this.pattern)}
373✔
449
                @change=${this.handleChange}
373✔
450
                @input=${this.handleInput}
373✔
451
                @focus=${this.onFocus}
373✔
452
                @blur=${this.onBlur}
373✔
453
                ?disabled=${this.disabled}
373✔
454
                ?required=${this.required}
373✔
455
                ?readonly=${this.readonly}
373✔
456
            />
373✔
457
            ${this.pendingStateController.renderPendingState()}
373✔
458
        `;
373✔
459
    }
373✔
460

3✔
461
    protected override render(): TemplateResult {
3✔
462
        const width = (this.input || this).offsetWidth;
373✔
463
        if (this.tooltipEl) {
373✔
464
            this.tooltipEl.disabled = this.open;
9✔
465
        }
9✔
466

373✔
467
        return html`
373✔
468
            ${super.render()}
373✔
469
            <sp-picker-button
373✔
470
                aria-controls="listbox-menu"
373✔
471
                aria-describedby="${this.helpTextId} tooltip"
373✔
472
                aria-expanded=${this.open ? 'true' : 'false'}
373✔
473
                aria-label=${ifDefined(this.label || this.appliedLabel)}
373✔
474
                aria-labelledby="applied-label label"
373✔
475
                @click=${this.toggleOpen}
373✔
476
                tabindex="-1"
373✔
477
                class="button ${this.focused
373✔
478
                    ? 'focus-visible is-keyboardFocused'
109✔
479
                    : ''}"
373✔
480
                ?disabled=${this.disabled}
373✔
481
                ?focused=${this.focused}
373✔
482
                ?quiet=${this.quiet}
373✔
483
                size=${this.size}
373✔
484
            ></sp-picker-button>
373✔
485
            <sp-overlay
373✔
486
                ?open=${this.open}
373✔
487
                .triggerElement=${this.input}
373✔
488
                offset="0"
373✔
489
                placement="bottom-start"
373✔
490
                .receivesFocus=${'false'}
373✔
491
                role="presentation"
373✔
492
            >
373✔
493
                <sp-popover
373✔
494
                    id="listbox"
373✔
495
                    ?open=${this.open}
373✔
496
                    role="presentation"
373✔
497
                    ?hidden=${this.availableOptions.length === 0}
373✔
498
                >
373✔
499
                    <sp-menu
373✔
500
                        @change=${this.handleMenuChange}
373✔
501
                        tabindex="-1"
373✔
502
                        aria-labelledby="label applied-label"
373✔
503
                        aria-label=${ifDefined(this.label || this.appliedLabel)}
373✔
504
                        id="listbox-menu"
373✔
505
                        role="listbox"
373✔
506
                        selects=${ifDefined(
373✔
507
                            this.autocomplete === 'none' ? 'single' : undefined
373✔
508
                        )}
373✔
509
                        .selected=${this.autocomplete === 'none' &&
373✔
510
                        this.itemValue
72✔
511
                            ? [this.itemValue]
6✔
512
                            : []}
373✔
513
                        style="min-width: ${width}px;"
373✔
514
                        size=${this.size}
373✔
515
                    >
373✔
516
                        ${this.overlayOpen
373✔
517
                            ? repeat(
135✔
518
                                  this.availableOptions,
135✔
519
                                  (option) => option.value,
135✔
520
                                  (option) => {
135✔
521
                                      return html`
5,728✔
522
                                          <sp-menu-item
5,728✔
523
                                              id="${option.value}"
5,728✔
524
                                              ?focused=${this.activeDescendant
5,728✔
525
                                                  ?.value === option.value}
5,728✔
526
                                              aria-selected=${this
5,728✔
527
                                                  .activeDescendant?.value ===
1,483✔
528
                                              option.value
5,728✔
529
                                                  ? 'true'
33✔
530
                                                  : 'false'}
5,728✔
531
                                              .value=${option.value}
5,728✔
532
                                              .selected=${option.value ===
5,728✔
533
                                              this.itemValue}
5,728✔
534
                                              ?disabled=${option.disabled}
5,728✔
535
                                          >
5,728✔
536
                                              ${option.itemText}
5,728✔
537
                                          </sp-menu-item>
5,728✔
538
                                      `;
5,728✔
539
                                  }
5,728✔
540
                              )
135✔
541
                            : html``}
373✔
542
                        <slot
373✔
543
                            hidden
373✔
544
                            @slotchange=${this.handleSlotchange}
373✔
545
                        ></slot>
373✔
546
                    </sp-menu>
373✔
547
                </sp-popover>
373✔
548
            </sp-overlay>
373✔
549
            ${this.renderAppliedLabel()}
373✔
550
            <slot
373✔
551
                aria-hidden="true"
373✔
552
                name="tooltip"
373✔
553
                id="tooltip"
373✔
554
                @slotchange=${this.handleTooltipSlotchange}
373✔
555
            ></slot>
373✔
556
        `;
373✔
557
    }
373✔
558

3✔
559
    applyFocusElementLabel = (value?: string): void => {
3✔
560
        this.appliedLabel = value;
4✔
561
    };
4✔
562

3✔
563
    protected override firstUpdated(
3✔
564
        changed: PropertyValues<this & { optionEls: MenuItem[] }>
67✔
565
    ): void {
67✔
566
        super.firstUpdated(changed);
67✔
567
        this.addEventListener('focusout', (event: FocusEvent) => {
67✔
568
            const isMenuItem =
49✔
569
                event.relatedTarget &&
49✔
570
                this.contains(event.relatedTarget as Node);
1✔
571
            if (event.target === this && !isMenuItem) {
49✔
572
                this.focused = false;
49✔
573
            }
49✔
574
        });
67✔
575
    }
67✔
576

3✔
577
    private _returnItems = (): void => {
3✔
578
        return;
5✔
579
    };
5✔
580

3✔
581
    protected async manageListOverlay(): Promise<void> {
3✔
582
        if (this.open) {
156✔
583
            this.focused = true;
44✔
584
            this.focus();
44✔
585
        }
44✔
586
    }
156✔
587

3✔
588
    protected override updated(
3✔
589
        changed: PropertyValues<
373✔
590
            this & { optionEls: MenuItem[]; activeDescendant: MenuItem }
373✔
591
        >
373✔
592
    ): void {
373✔
593
        if (changed.has('open') && !this.pending) {
373✔
594
            this.manageListOverlay();
156✔
595
        }
156✔
596
        if (!this.focused && this.open) {
373✔
597
            this.open = false;
1✔
598
        }
1✔
599
        if (changed.has('pending') && this.pending) {
373✔
600
            this.open = false;
5✔
601
        }
5✔
602
        if (changed.has('activeDescendant')) {
373✔
603
            const previouslyActiveDescendant = changed.get(
52✔
604
                'activeDescendant'
52✔
605
            ) as unknown as MenuItem;
52✔
606
            if (previouslyActiveDescendant) {
52✔
607
                previouslyActiveDescendant.focused = false;
32✔
608
            }
32✔
609
            if (
52✔
610
                this.activeDescendant &&
52✔
611
                typeof (this.activeDescendant as MenuItem).focused !==
32✔
612
                    'undefined'
32✔
613
            ) {
52✔
614
                (this.activeDescendant as MenuItem).focused = true;
18✔
615
            }
18✔
616
        }
52✔
617
        if (changed.has('options') || changed.has('optionEls')) {
373✔
618
            // if all options are disabled, set combobox to disabled
141✔
619
            if (this.options?.every((option) => option.disabled)) {
141!
620
                this.disabled = true;
×
621
            }
×
622

141✔
623
            this.availableOptions = this.options || this.optionEls;
141✔
624
        }
141✔
625
    }
373✔
626

3✔
627
    protected override async getUpdateComplete(): Promise<boolean> {
3✔
628
        const complete = await super.getUpdateComplete();
301✔
629
        const list = this.shadowRoot.querySelector(
301✔
630
            '#listbox'
301✔
631
        ) as HTMLUListElement;
301✔
632
        if (list) {
301✔
633
            const descendants = [...list.children] as SpectrumElement[];
301✔
634
            await Promise.all(
301✔
635
                descendants.map((descendant) => descendant.updateComplete)
301✔
636
            );
301✔
637
        }
301✔
638
        return complete;
301✔
639
    }
301✔
640

3✔
641
    public override connectedCallback(): void {
3✔
642
        super.connectedCallback();
67✔
643
        if (!this.itemObserver) {
67✔
644
            this.itemObserver = new MutationObserver(
67✔
645
                this.setOptionsFromSlottedItems.bind(this)
67✔
646
            );
67✔
647
        }
67✔
648
    }
67✔
649

3✔
650
    public override disconnectedCallback(): void {
3✔
651
        this.itemObserver.disconnect();
67✔
652
        this.open = false;
67✔
653
        super.disconnectedCallback();
67✔
654
    }
67✔
655

3✔
656
    private itemObserver!: MutationObserver;
3✔
657
}
3✔
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