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

adobe / spectrum-web-components / 18209190251

03 Oct 2025 12:24AM UTC coverage: 97.984% (+0.07%) from 97.919%
18209190251

Pull #5730

github

web-flow
Merge 7edc5d325 into a2536708a
Pull Request #5730: fix(pending-state): correct reflection of aria in pending controller

5352 of 5641 branches covered (94.88%)

Branch coverage included in aggregate %.

96 of 96 new or added lines in 5 files covered. (100.0%)

10 existing lines in 1 file now uncovered.

34074 of 34596 relevant lines covered (98.49%)

629.18 hits per line

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

96.48
/packages/picker/src/Picker.ts
1
/**
29✔
2
 * Copyright 2025 Adobe. All rights reserved.
29✔
3
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
29✔
4
 * you may not use this file except in compliance with the License. You may obtain a copy
29✔
5
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
29✔
6
 *
29✔
7
 * Unless required by applicable law or agreed to in writing, software distributed under
29✔
8
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
29✔
9
 * OF ANY KIND, either express or implied. See the License for the specific language
29✔
10
 * governing permissions and limitations under the License.
29✔
11
 */
29✔
12

29✔
13
import {
29✔
14
    CSSResultArray,
29✔
15
    DefaultElementSize,
29✔
16
    html,
29✔
17
    nothing,
29✔
18
    PropertyValues,
29✔
19
    render,
29✔
20
    SizedMixin,
29✔
21
    SpectrumElement,
29✔
22
    TemplateResult,
29✔
23
} from '@spectrum-web-components/base';
29✔
24
import {
29✔
25
    property,
29✔
26
    query,
29✔
27
    state,
29✔
28
} from '@spectrum-web-components/base/src/decorators.js';
29✔
29
import {
29✔
30
    classMap,
29✔
31
    ifDefined,
29✔
32
    StyleInfo,
29✔
33
    styleMap,
29✔
34
} from '@spectrum-web-components/base/src/directives.js';
29✔
35
import type { FieldLabel } from '@spectrum-web-components/field-label';
29✔
36
import chevronStyles from '@spectrum-web-components/icon/src/spectrum-icon-chevron.css.js';
29✔
37
import '@spectrum-web-components/icons-ui/icons/sp-icon-chevron100.js';
29✔
38
import '@spectrum-web-components/icons-workflow/icons/sp-icon-alert.js';
29✔
39
import type {
29✔
40
    Menu,
29✔
41
    MenuItem,
29✔
42
    MenuItemChildren,
29✔
43
    MenuItemKeydownEvent,
29✔
44
} from '@spectrum-web-components/menu';
29✔
45
import '@spectrum-web-components/menu/sp-menu.js';
29✔
46
import { Placement } from '@spectrum-web-components/overlay';
29✔
47
import { Overlay } from '@spectrum-web-components/overlay/src/Overlay.js';
29✔
48
import type { SlottableRequestEvent } from '@spectrum-web-components/overlay/src/slottable-request-event.js';
29✔
49
import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js';
29✔
50
import {
29✔
51
    IS_MOBILE,
29✔
52
    MatchMediaController,
29✔
53
} from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js';
29✔
54
import type { Tooltip } from '@spectrum-web-components/tooltip';
29✔
55
import { DesktopController } from './DesktopController.js';
29✔
56
import { MobileController } from './MobileController.js';
29✔
57
import pickerStyles from './picker.css.js';
29✔
58
import { strategies } from './strategies.js';
29✔
59

29✔
60
const chevronClass = {
29✔
61
    s: 'spectrum-UIIcon-ChevronDown75',
29✔
62
    m: 'spectrum-UIIcon-ChevronDown100',
29✔
63
    l: 'spectrum-UIIcon-ChevronDown200',
29✔
64
    xl: 'spectrum-UIIcon-ChevronDown300',
29✔
65
};
29✔
66

29✔
67
export const DESCRIPTION_ID = 'option-picker';
29✔
68

29✔
69
/**
29✔
70
 * @element sp-picker
29✔
71
 * @slot label - The placeholder content for the Picker
29✔
72
 * @slot description - The description content for the Picker
29✔
73
 * @slot tooltip - Tooltip to to be applied to the the Picker Button
29✔
74
 * @slot - menu items to be listed in the Picker
29✔
75
 * @fires change - Announces that the `value` of the element has changed
29✔
76
 * @fires sp-opened - Announces that the overlay has been opened
29✔
77
 */
29✔
78
export class PickerBase extends SizedMixin(SpectrumElement, {
29✔
79
    noDefaultSize: true,
29✔
80
}) {
29✔
81
    static override shadowRootOptions = {
408✔
82
        ...SpectrumElement.shadowRootOptions,
408✔
83
        delegatesFocus: true,
408✔
84
    };
408✔
85

408✔
86
    public isMobile = new MatchMediaController(this, IS_MOBILE);
408✔
87

408✔
88
    public strategy!: DesktopController | MobileController;
408✔
89

408✔
90
    @state()
408✔
91
    appliedLabel?: string;
408✔
92

408✔
93
    @query('#button')
408✔
94
    public button!: HTMLButtonElement;
408✔
95

408✔
96
    public dependencyManager = new DependencyManagerController(this);
408✔
97

408✔
98
    private deprecatedMenu: Menu | null = null;
408✔
99

408✔
100
    @property({ type: Boolean, reflect: true })
408✔
101
    public disabled = false;
408✔
102

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

408✔
106
    @property({ type: String, reflect: true })
408✔
107
    public icons?: 'only' | 'none';
408✔
108

408✔
109
    @property({ type: Boolean, reflect: true })
408✔
110
    public invalid = false;
408✔
111

408✔
112
    /**
408✔
113
     * Forces the Picker to render as a popover on mobile instead of a tray.
408✔
114
     *
408✔
115
     * @memberof PickerBase
408✔
116
     */
408✔
117
    @property({ type: Boolean, reflect: true, attribute: 'force-popover' })
408✔
118
    public forcePopover = false;
408✔
119

408✔
120
    /** Whether the items are currently loading. */
408✔
121
    @property({ type: Boolean, reflect: true })
408✔
122
    public pending = false;
408✔
123

408✔
124
    /** Defines a string value that labels the Picker while it is in pending state. */
408✔
125
    @property({ type: String, attribute: 'pending-label' })
408✔
126
    public pendingLabel = 'Pending';
408✔
127

408✔
128
    @property()
408✔
129
    public label?: string;
408✔
130

408✔
131
    @property({ type: Boolean, reflect: true })
408✔
132
    public open = false;
408✔
133

408✔
134
    @property({ type: Boolean, reflect: true })
408✔
135
    public readonly = false;
408✔
136

408✔
137
    public selects: undefined | 'single' = 'single';
408✔
138

408✔
139
    @state()
408✔
140
    public labelAlignment?: 'inline';
408✔
141

408✔
142
    protected get menuItems(): MenuItem[] {
408✔
143
        return this.optionsMenu.childItems;
408✔
144
    }
408✔
145

408✔
146
    @query('sp-menu')
408✔
147
    public optionsMenu!: Menu;
408✔
148

408✔
149
    /**
408✔
150
     * @deprecated
408✔
151
     * */
408✔
152
    public get selfManageFocusElement(): boolean {
408✔
153
        return true;
×
154
    }
×
155

408✔
156
    @query('sp-overlay')
408✔
157
    public overlayElement!: Overlay;
408✔
158

408✔
159
    protected tooltipEl?: Tooltip;
408✔
160

408✔
161
    /**
408✔
162
     * @type {"top" | "top-start" | "top-end" | "right" | "right-start" | "right-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end"}
408✔
163
     * @attr
408✔
164
     */
408✔
165

408✔
166
    @property()
408✔
167
    public placement: Placement = 'bottom-start';
408✔
168

408✔
169
    @property({ type: Boolean, reflect: true })
408✔
170
    public quiet = false;
408✔
171

408✔
172
    @property({ type: String })
408✔
173
    public value = '';
408✔
174

408✔
175
    @property({ attribute: false })
408✔
176
    public get selectedItem(): MenuItem | undefined {
408✔
177
        return this._selectedItem;
2,289✔
178
    }
2,289✔
179

408✔
180
    public set selectedItem(selectedItem: MenuItem | undefined) {
408✔
181
        this.selectedItemContent = selectedItem
367✔
182
            ? selectedItem.itemChildren
130✔
183
            : undefined;
237✔
184

367✔
185
        if (selectedItem === this.selectedItem) return;
367✔
186
        const oldSelectedItem = this.selectedItem;
103✔
187
        this._selectedItem = selectedItem;
103✔
188
        this.requestUpdate('selectedItem', oldSelectedItem);
103✔
189
    }
367✔
190

408✔
191
    _selectedItem?: MenuItem;
408✔
192

408✔
193
    protected listRole: 'listbox' | 'menu' = 'listbox';
408✔
194
    protected itemRole = 'option';
408✔
195

408✔
196
    public get focusElement(): HTMLElement {
408✔
197
        if (this.open) {
374✔
198
            return this.optionsMenu;
20✔
199
        }
20✔
200
        return this.button;
354✔
201
    }
374✔
202

408✔
203
    public forceFocusVisible(): void {
408✔
204
        if (this.disabled) {
5✔
205
            return;
2✔
206
        }
2✔
207

3✔
208
        this.focused = true;
3✔
209
    }
5✔
210

408✔
211
    // handled by interaction controller, desktop or mobile; this is only called with a programmatic this.click()
408✔
212
    public override click(): void {
408✔
213
        this.toggle();
60✔
214
    }
60✔
215

408✔
216
    // pointer events handled by interaction controller, desktop or mobile; this is only called with a programmatic this.button.click()
408✔
217
    public handleButtonClick(): void {
408✔
218
        if (this.disabled) {
×
219
            return;
×
220
        }
×
221
        this.toggle();
×
222
    }
×
223

408✔
224
    public handleButtonBlur(): void {
408✔
225
        this.focused = false;
92✔
226
    }
92✔
227

408✔
228
    public override focus(options?: FocusOptions): void {
408✔
229
        this.focusElement?.focus(options);
45✔
230
    }
45✔
231
    /**
408✔
232
     * @deprecated - Use `focus` instead.
408✔
233
     */
408✔
234
    public handleHelperFocus(): void {
408✔
235
        // set focused to true here instead of handleButtonFocus so clicks don't flash a focus outline
×
236
        this.focused = true;
×
237
        this.button.focus();
×
238
    }
×
239

408✔
240
    public handleFocus(): void {
408✔
241
        if (!this.disabled && this.focusElement) {
92✔
242
            this.focused = this.hasVisibleFocusInTree();
92✔
243
        }
92✔
244
    }
92✔
245

408✔
246
    public handleChange(event: Event): void {
408✔
247
        if (this.strategy) {
51✔
248
            this.strategy.preventNextToggle = 'no';
51✔
249
        }
51✔
250
        const target = event.target as Menu;
51✔
251
        const [selected] = target.selectedItems;
51✔
252
        event.stopPropagation();
51✔
253
        if (event.cancelable) {
51✔
254
            this.setValueFromItem(selected, event);
47✔
255
        } else {
51✔
256
            // Non-cancelable "change" events announce a selection with no value
4✔
257
            // change that should close the Picker element.
4✔
258
            this.open = false;
4✔
259
            if (this.strategy) {
4✔
260
                this.strategy.open = false;
4✔
261
            }
4✔
262
        }
4✔
263
    }
51✔
264

408✔
265
    public handleButtonFocus(event: FocusEvent): void {
408✔
266
        this.strategy?.handleButtonFocus(event);
92✔
267
    }
92✔
268

408✔
269
    protected handleEscape = (
408✔
270
        event: MenuItemKeydownEvent | KeyboardEvent
32✔
271
    ): void => {
32✔
272
        if (event.key === 'Escape' && this.open) {
32✔
273
            event.stopPropagation();
8✔
274
            event.preventDefault();
8✔
275
            this.toggle(false);
8✔
276
        }
8✔
277
    };
32✔
278

408✔
279
    protected handleKeydown = (event: KeyboardEvent): void => {
408✔
280
        this.focused = true;
10✔
281
        if (
10✔
282
            !['ArrowUp', 'ArrowDown', 'Enter', ' ', 'Escape'].includes(
10✔
283
                event.key
10✔
284
            )
10✔
285
        ) {
10✔
UNCOV
286
            return;
×
UNCOV
287
        }
×
288
        if (event.key === 'Escape') {
10✔
UNCOV
289
            this.handleEscape(event);
×
UNCOV
290
            return;
×
UNCOV
291
        }
×
292
        event.stopPropagation();
10✔
293
        event.preventDefault();
10✔
294
        this.keyboardOpen();
10✔
295
    };
10✔
296

408✔
297
    protected async keyboardOpen(): Promise<void> {
408✔
298
        // if the menu is not open, we need to toggle it and wait for it to open to focus on the first selected item
30✔
299
        if (!this.open || !this.strategy.open) {
30✔
300
            this.addEventListener(
30✔
301
                'sp-opened',
30✔
302
                () => this.optionsMenu?.focusOnFirstSelectedItem(),
30✔
303
                {
30✔
304
                    once: true,
30✔
305
                }
30✔
306
            );
30✔
307
            this.toggle(true);
30✔
308
        } else {
30✔
309
            // if the menu is already open, we need to focus on the first selected item
×
310
            this.optionsMenu?.focusOnFirstSelectedItem();
×
311
        }
×
312
    }
30✔
313

408✔
314
    protected async setValueFromItem(
408✔
315
        item: MenuItem,
59✔
316
        menuChangeEvent?: Event
59✔
317
    ): Promise<void> {
59✔
318
        this.open = false;
59✔
319
        // should always close when "setting" a value
59✔
320
        const oldSelectedItem = this.selectedItem;
59✔
321
        const oldValue = this.value;
59✔
322

59✔
323
        // Set a value.
59✔
324
        this.selectedItem = item;
59✔
325
        this.value = item?.value ?? '';
59✔
326
        await this.updateComplete;
59✔
327
        const applyDefault = this.dispatchEvent(
59✔
328
            new Event('change', {
59✔
329
                bubbles: true,
59✔
330
                // Allow it to be prevented.
59✔
331
                cancelable: true,
59✔
332
                composed: true,
59✔
333
            })
59✔
334
        );
59✔
335
        if (!applyDefault && this.selects) {
59✔
336
            if (menuChangeEvent) {
2✔
337
                menuChangeEvent.preventDefault();
2✔
338
            }
2✔
339
            this.setMenuItemSelected(this.selectedItem as MenuItem, false);
2✔
340
            if (oldSelectedItem) {
2✔
341
                this.setMenuItemSelected(oldSelectedItem, true);
×
342
            }
×
343
            this.selectedItem = oldSelectedItem;
2✔
344
            this.value = oldValue;
2✔
345
            this.open = true;
2✔
346
            if (this.strategy) {
2✔
347
                this.strategy.open = true;
2✔
348
            }
2✔
349
            return;
2✔
350
        } else if (!this.selects) {
59✔
351
            // Unset the value if not carrying a selection
18✔
352
            this.selectedItem = oldSelectedItem;
18✔
353
            this.value = oldValue;
18✔
354
            return;
18✔
355
        }
18✔
356
        if (oldSelectedItem) {
41✔
357
            this.setMenuItemSelected(oldSelectedItem, false);
14✔
358
        }
14✔
359
        this.setMenuItemSelected(item, !!this.selects);
39✔
360
    }
59✔
361

408✔
362
    protected setMenuItemSelected(item: MenuItem, value: boolean): void {
408✔
363
        // matches null | undefined
55✔
364
        if (this.selects == null) return;
55✔
365
        item.selected = value;
55✔
366
    }
55✔
367

408✔
368
    public toggle(target?: boolean): void {
408✔
369
        if (this.readonly || this.pending || this.disabled) {
119✔
370
            return;
6✔
371
        }
6✔
372
        const open = typeof target !== 'undefined' ? target : !this.open;
119✔
373

119✔
374
        this.open = open;
119✔
375
        if (this.strategy) {
119✔
376
            this.strategy.open = this.open;
113✔
377
        }
113✔
378
    }
119✔
379

408✔
380
    public close(): void {
408✔
381
        if (this.readonly) {
600✔
382
            return;
2✔
383
        }
2✔
384
        if (this.strategy) {
600✔
385
            this.open = false;
586✔
386
            this.strategy.open = false;
586✔
387
        }
586✔
388
    }
600✔
389

408✔
390
    protected get containerStyles(): StyleInfo {
408✔
391
        // @todo: test in mobile
442✔
392
        /* c8 ignore next 5 */
29✔
393
        if (this.isMobile.matches) {
29✔
394
            return {
29✔
395
                '--swc-menu-width': '100%',
29✔
396
            };
29✔
397
        }
29✔
398
        return {};
431✔
399
    }
442✔
400

408✔
401
    @state()
408✔
402
    protected get selectedItemContent(): MenuItemChildren {
408✔
403
        return this._selectedItemContent || { icon: [], content: [] };
3,893✔
404
    }
3,893✔
405

408✔
406
    protected set selectedItemContent(
408✔
407
        selectedItemContent: MenuItemChildren | undefined
367✔
408
    ) {
367✔
409
        if (selectedItemContent === this.selectedItemContent) return;
367✔
410

323✔
411
        const oldContent = this.selectedItemContent;
323✔
412
        this._selectedItemContent = selectedItemContent;
323✔
413
        this.requestUpdate('selectedItemContent', oldContent);
323✔
414
    }
367✔
415

408✔
416
    _selectedItemContent?: MenuItemChildren;
408✔
417

408✔
418
    protected handleTooltipSlotchange(
408✔
419
        event: Event & { target: HTMLSlotElement }
178✔
420
    ): void {
178✔
421
        this.tooltipEl = event.target.assignedElements()[0] as
178✔
422
            | Tooltip
178✔
423
            | undefined;
178✔
424

178✔
425
        // Set up trigger element for self-managed tooltips
178✔
426
        if (this.tooltipEl?.selfManaged) {
178✔
427
            // Wait for the tooltip to be fully initialized
178✔
428
            this.updateComplete.then(() => {
178✔
429
                if (this.tooltipEl?.overlayElement && this.button) {
178✔
430
                    this.tooltipEl.overlayElement.triggerElement = this.button;
178✔
431
                }
178✔
432
            });
178✔
433
        }
178✔
434
    }
178✔
435

408✔
436
    public handleSlottableRequest = (_event: SlottableRequestEvent): void => {};
408✔
437

408✔
438
    protected renderLabelContent(content: Node[]): TemplateResult | Node[] {
408✔
439
        if (this.value && this.selectedItem) {
869✔
440
            return content;
109✔
441
        }
109✔
442
        return html`
760✔
443
            <slot name="label" id="label">
760✔
444
                <span
760✔
445
                    aria-hidden=${ifDefined(
760✔
446
                        this.appliedLabel ? undefined : 'true'
869✔
447
                    )}
869✔
448
                >
869✔
449
                    ${this.label}
869✔
450
                </span>
869✔
451
            </slot>
869✔
452
        `;
869✔
453
    }
869✔
454

408✔
455
    protected renderLoader(): TemplateResult {
408✔
456
        import(
22✔
457
            '@spectrum-web-components/progress-circle/sp-progress-circle.js'
22✔
458
        );
22✔
459
        return html`
22✔
460
            <sp-progress-circle
22✔
461
                size="s"
22✔
462
                indeterminate
22✔
463
                role="presentation"
22✔
464
                class="progress-circle"
22✔
465
            ></sp-progress-circle>
22✔
466
        `;
22✔
467
    }
22✔
468

408✔
469
    protected get buttonContent(): TemplateResult[] {
408✔
470
        const labelClasses = {
869✔
471
            'visually-hidden': this.icons === 'only' && !!this.value,
869✔
472
            placeholder: !this.value,
869✔
473
            label: true,
869✔
474
        };
869✔
475
        const appliedLabel = this.appliedLabel || this.label;
869✔
476
        return [
869✔
477
            html`
869✔
478
                <span id="icon" ?hidden=${this.icons === 'none'}>
869✔
479
                    ${this.selectedItemContent.icon}
869✔
480
                </span>
869✔
481
                <span
869✔
482
                    id=${ifDefined(
869✔
483
                        this.value && this.selectedItem ? 'label' : undefined
869✔
484
                    )}
869✔
485
                    class=${classMap(labelClasses)}
869✔
486
                >
869✔
487
                    ${this.renderLabelContent(this.selectedItemContent.content)}
869✔
488
                </span>
869✔
489
                ${this.value && this.selectedItem
869✔
490
                    ? html`
109✔
491
                          <span
109✔
492
                              aria-hidden="true"
109✔
493
                              class="visually-hidden"
109✔
494
                              id="applied-label"
109✔
495
                          >
109✔
496
                              ${appliedLabel}
109✔
497
                              <slot name="label"></slot>
760✔
498
                          </span>
760✔
499
                      `
760✔
500
                    : html`
760✔
501
                          <span hidden id="applied-label">${appliedLabel}</span>
760✔
502
                      `}
869✔
503
                ${this.invalid && !this.pending
869✔
504
                    ? html`
2✔
505
                          <sp-icon-alert
867✔
506
                              class="validation-icon"
867✔
507
                          ></sp-icon-alert>
867✔
508
                      `
867✔
509
                    : nothing}
869✔
510
                ${this.pending
869✔
511
                    ? html`
22✔
512
                          ${this.renderLoader()}
22✔
513
                          <span
22✔
514
                              aria-hidden="true"
22✔
515
                              class="visually-hidden"
22✔
516
                              id="pending-label"
22✔
517
                          >
22✔
518
                              ${this.pendingLabel}
22✔
519
                          </span>
847✔
520
                      `
847✔
521
                    : nothing}
869✔
522
                <sp-icon-chevron100
869✔
523
                    class="picker ${chevronClass[
869✔
524
                        this.size as DefaultElementSize
869✔
525
                    ]}"
869✔
526
                ></sp-icon-chevron100>
869✔
527
            `,
869✔
528
        ];
869✔
529
    }
869✔
530

408✔
531
    applyFocusElementLabel = (
408✔
532
        value: string,
230✔
533
        labelElement: FieldLabel
230✔
534
    ): void => {
230✔
535
        this.appliedLabel = value;
230✔
536
        this.labelAlignment = labelElement.sideAligned ? 'inline' : undefined;
230✔
537
    };
230✔
538

408✔
539
    protected hasAccessibleLabel(): boolean {
408✔
540
        const slotContent =
864✔
541
            this.querySelector('[slot="label"]')?.textContent &&
864✔
542
            this.querySelector('[slot="label"]')?.textContent?.trim() !== '';
14✔
543
        const slotAlt =
864✔
544
            this.querySelector('[slot="label"]')?.getAttribute('alt')?.trim() &&
864✔
545
            this.querySelector('[slot="label"]')
×
546
                ?.getAttribute('alt')
×
547
                ?.trim() !== '';
×
548
        return (
864✔
549
            !!this.label ||
864✔
550
            !!this.getAttribute('aria-label') ||
55✔
551
            !!this.getAttribute('aria-labelledby') ||
55✔
552
            !!this.appliedLabel ||
55✔
553
            !!slotContent ||
17✔
554
            !!slotAlt
9✔
555
        );
864✔
556
    }
864✔
557

408✔
558
    protected warnNoLabel(): void {
408✔
559
        window.__swc.warn(
9✔
560
            this,
9✔
561
            `<${this.localName}> needs one of the following to be accessible:`,
9✔
562
            'https://opensource.adobe.com/spectrum-web-components/components/picker/#accessibility',
9✔
563
            {
9✔
564
                type: 'accessibility',
9✔
565
                issues: [
9✔
566
                    `an <sp-field-label> element with a \`for\` attribute referencing the \`id\` of the \`<${this.localName}>\`, or`,
9✔
567
                    'value supplied to the "label" attribute, which will be displayed visually as placeholder text, or',
9✔
568
                    'text content supplied in a <span> with slot="label", which will also be displayed visually as placeholder text.',
9✔
569
                ],
9✔
570
            }
9✔
571
        );
9✔
572
    }
9✔
573

408✔
574
    protected renderOverlay(menu: TemplateResult): TemplateResult {
408✔
575
        if (this.strategy?.overlay === undefined) {
665✔
576
            return menu;
223✔
577
        }
223✔
578
        const container = this.renderContainer(menu);
442✔
579
        render(container, this.strategy?.overlay as unknown as HTMLElement, {
665✔
580
            host: this,
665✔
581
        });
665✔
582
        return this.strategy?.overlay as unknown as TemplateResult;
665✔
583
    }
665✔
584

408✔
585
    protected get renderDescriptionSlot(): TemplateResult {
408✔
586
        return html`
1,371✔
587
            <div id=${DESCRIPTION_ID}>
1,371✔
588
                <slot name="description"></slot>
1,371✔
589
            </div>
1,371✔
590
        `;
1,371✔
591
    }
1,371✔
592
    // a helper to throw focus to the button is needed because Safari
408✔
593
    // won't include buttons in the tab order even with tabindex="0"
408✔
594
    protected override render(): TemplateResult {
408✔
595
        if (this.tooltipEl) {
869✔
596
            this.tooltipEl.disabled = this.open;
16✔
597
        }
16✔
598
        return html`
869✔
599
            <button
869✔
600
                aria-controls=${ifDefined(this.open ? 'menu' : undefined)}
869✔
601
                aria-describedby="tooltip ${DESCRIPTION_ID}"
869✔
602
                aria-expanded=${this.open ? 'true' : 'false'}
869✔
603
                aria-haspopup="true"
869✔
604
                aria-labelledby="icon label applied-label pending-label"
869✔
605
                id="button"
869✔
606
                class=${ifDefined(
869✔
607
                    this.labelAlignment
869✔
608
                        ? `label-${this.labelAlignment}`
×
609
                        : undefined
869✔
610
                )}
869✔
611
                @focus=${this.handleButtonFocus}
869✔
612
                @blur=${this.handleButtonBlur}
869✔
613
                @keydown=${{
869✔
614
                    handleEvent: this.handleEnterKeydown,
869✔
615
                    capture: true,
869✔
616
                }}
869✔
617
                ?disabled=${this.disabled}
869✔
618
            >
869✔
619
                ${this.buttonContent}
869✔
620
            </button>
869✔
621
            <slot
869✔
622
                aria-hidden="true"
869✔
623
                name="tooltip"
869✔
624
                id="tooltip"
869✔
625
                @keydown=${this.handleKeydown}
869✔
626
                @slotchange=${this.handleTooltipSlotchange}
869✔
627
            ></slot>
869✔
628
            ${this.renderMenu} ${this.renderDescriptionSlot}
869✔
629
        `;
869✔
630
    }
869✔
631

408✔
632
    protected override willUpdate(changes: PropertyValues<this>): void {
408✔
633
        super.willUpdate(changes);
1,371✔
634
        if (changes.has('tabIndex') && !!this.tabIndex) {
1,371!
635
            this.button.tabIndex = this.tabIndex;
×
636
            this.removeAttribute('tabindex');
×
637
        }
×
638
    }
1,371✔
639

408✔
640
    protected override update(changes: PropertyValues<this>): void {
408✔
641
        if (this.selects) {
1,371✔
642
            /**
894✔
643
             * Always force `selects` to "single" when set.
894✔
644
             *
894✔
645
             * @todo: Add support functionally and visually for "multiple"
894✔
646
             */
894✔
647
            this.selects = 'single';
894✔
648
        }
894✔
649
        if (changes.has('disabled') && this.disabled) {
1,371✔
650
            this.close();
9✔
651
        }
9✔
652
        if (changes.has('pending') && this.pending) {
1,371✔
653
            this.close();
6✔
654
        }
6✔
655
        if (changes.has('value')) {
1,371✔
656
            // MenuItems update a frame late for <slot> management,
521✔
657
            // await the same here.
521✔
658
            this.shouldScheduleManageSelection();
521✔
659
        }
521✔
660
        // Maybe it's finally time to remove this support?s
1,371✔
661
        if (!this.hasUpdated) {
1,371✔
662
            this.deprecatedMenu = this.querySelector(':scope > sp-menu');
408✔
663
            this.deprecatedMenu?.toggleAttribute('ignore', true);
408✔
664
            this.deprecatedMenu?.setAttribute('selects', 'inherit');
408✔
665
        }
408✔
666
        if (window.__swc.DEBUG) {
1,371✔
667
            if (!this.hasUpdated && this.querySelector(':scope > sp-menu')) {
1,371✔
668
                const { localName } = this;
7✔
669
                window.__swc.warn(
7✔
670
                    this,
7✔
671
                    `You no longer need to provide an <sp-menu> child to ${localName}. Any styling or attributes on the <sp-menu> will be ignored.`,
7✔
672
                    'https://opensource.adobe.com/spectrum-web-components/components/picker/#sizes',
7✔
673
                    { level: 'deprecation' }
7✔
674
                );
7✔
675
            }
7✔
676
            this.updateComplete.then(async () => {
1,371✔
677
                // Attributes should be user supplied, making them available before first update.
1,371✔
678
                // However, `appliesLabel` is applied by external elements that must be update complete as well to be bound appropriately.
1,371✔
679
                await new Promise((res) => requestAnimationFrame(res));
1,371✔
680
                await new Promise((res) => requestAnimationFrame(res));
1,360✔
681
                if (!this.hasAccessibleLabel()) {
1,371✔
682
                    this.warnNoLabel();
11✔
683
                }
11✔
684
            });
1,371✔
685
        }
1,371✔
686
        super.update(changes);
1,371✔
687
    }
1,371✔
688

408✔
689
    protected bindButtonKeydownListener(): void {
408✔
690
        this.button.addEventListener('keydown', this.handleKeydown);
408✔
691
    }
408✔
692

408✔
693
    protected override updated(changes: PropertyValues<this>): void {
408✔
694
        super.updated(changes);
1,371✔
695
        if (changes.has('open')) {
1,371✔
696
            this.strategy.open = this.open;
679✔
697
        }
679✔
698
    }
1,371✔
699

408✔
700
    protected override firstUpdated(changes: PropertyValues<this>): void {
408✔
701
        super.firstUpdated(changes);
408✔
702
        this.bindButtonKeydownListener();
408✔
703
        this.bindEvents();
408✔
704
    }
408✔
705

408✔
706
    protected get dismissHelper(): TemplateResult {
408✔
707
        return html`
884✔
708
            <div class="visually-hidden">
884✔
709
                <button
884✔
710
                    tabindex="-1"
884✔
711
                    aria-label="Dismiss"
884✔
712
                    @click=${this.close}
884✔
713
                ></button>
884✔
714
            </div>
884✔
715
        `;
884✔
716
    }
884✔
717

408✔
718
    protected renderContainer(menu: TemplateResult): TemplateResult {
408✔
719
        const accessibleMenu = html`
442✔
720
            ${this.dismissHelper} ${menu} ${this.dismissHelper}
442✔
721
        `;
442✔
722
        // @todo: test in mobile
442✔
723
        if (this.isMobile.matches && !this.forcePopover) {
442✔
724
            this.dependencyManager.add('sp-tray');
5✔
725
            import('@spectrum-web-components/tray/sp-tray.js');
5✔
726
            return html`
5✔
727
                <sp-tray
5✔
728
                    id="popover"
5✔
729
                    role="presentation"
5✔
730
                    style=${styleMap(this.containerStyles)}
5✔
731
                >
5✔
732
                    ${accessibleMenu}
5✔
733
                </sp-tray>
5✔
734
            `;
5✔
735
        }
5✔
736
        this.dependencyManager.add('sp-popover');
437✔
737
        import('@spectrum-web-components/popover/sp-popover.js');
437✔
738
        return html`
437✔
739
            <sp-popover
437✔
740
                id="popover"
437✔
741
                role="presentation"
437✔
742
                style=${styleMap(this.containerStyles)}
437✔
743
                placement=${this.placement}
437✔
744
            >
437✔
745
                ${accessibleMenu}
437✔
746
            </sp-popover>
442✔
747
        `;
442✔
748
    }
442✔
749

408✔
750
    protected hasRenderedOverlay = false;
408✔
751

408✔
752
    private onScroll(): void {
408✔
753
        this.dispatchEvent(
×
754
            new Event('scroll', {
×
755
                cancelable: true,
×
756
                composed: true,
×
757
            })
×
758
        );
×
759
    }
×
760

408✔
761
    protected get renderMenu(): TemplateResult {
408✔
762
        const menu = html`
1,371✔
763
            <sp-menu
1,371✔
764
                aria-labelledby="applied-label"
1,371✔
765
                @change=${this.handleChange}
1,371✔
766
                id="menu"
1,371✔
767
                @keydown=${{
1,371✔
768
                    handleEvent: this.handleEnterKeydown,
1,371✔
769
                    capture: true,
1,371✔
770
                }}
1,371✔
771
                @scroll=${this.onScroll}
1,371✔
772
                role=${this.listRole}
1,371✔
773
                .selects=${this.selects}
1,371✔
774
                .selected=${this.value ? [this.value] : []}
1,371✔
775
                size=${this.size}
1,371✔
776
                @sp-menu-item-keydown=${this.handleEscape}
1,371✔
777
                @sp-menu-item-added-or-updated=${this.shouldManageSelection}
1,371✔
778
            >
1,371✔
779
                <slot @slotchange=${this.shouldScheduleManageSelection}></slot>
1,371✔
780
            </sp-menu>
1,371✔
781
        `;
1,371✔
782
        this.hasRenderedOverlay =
1,371✔
783
            this.hasRenderedOverlay ||
1,371✔
784
            this.focused ||
834✔
785
            this.open ||
787✔
786
            !!this.deprecatedMenu;
713✔
787
        if (this.hasRenderedOverlay) {
1,371✔
788
            if (this.dependencyManager.loaded) {
665✔
789
                this.dependencyManager.add('sp-overlay');
330✔
790
            }
330✔
791
            return this.renderOverlay(menu);
665✔
792
        }
665✔
793
        return menu;
706✔
794
    }
1,371✔
795

408✔
796
    /**
408✔
797
     * whether a selection change is already scheduled
408✔
798
     */
408✔
799
    public willManageSelection = false;
408✔
800

408✔
801
    /**
408✔
802
     * when the value changes or the menu slot changes, manage the selection on the next frame, if not already scheduled
408✔
803
     * @param event
408✔
804
     */
408✔
805
    protected shouldScheduleManageSelection(event?: Event): void {
408✔
806
        if (
1,174✔
807
            !this.willManageSelection &&
1,174✔
808
            (!event ||
594✔
809
                ((event.target as HTMLElement).getRootNode() as ShadowRoot)
114✔
810
                    .host === this)
114✔
811
        ) {
1,174✔
812
            //s set a flag to manage selection on the next frame
538✔
813
            this.willManageSelection = true;
538✔
814
            requestAnimationFrame(() => {
538✔
815
                requestAnimationFrame(() => {
538✔
816
                    this.manageSelection();
535✔
817
                });
538✔
818
            });
538✔
819
        }
538✔
820
    }
1,174✔
821

408✔
822
    /**
408✔
823
     * when an item is added or updated, manage the selection, if it's not already scheduled
408✔
824
     */
408✔
825
    protected shouldManageSelection(): void {
408✔
826
        if (this.willManageSelection) {
2,547✔
827
            return;
2,530✔
828
        }
2,530✔
829
        this.willManageSelection = true;
17✔
830
        this.manageSelection();
17✔
831
    }
2,547✔
832

408✔
833
    /**
408✔
834
     * updates menu selection based on value
408✔
835
     */
408✔
836
    protected async manageSelection(): Promise<void> {
408✔
837
        if (this.selects == null) return;
552!
838

291✔
839
        this.selectionPromise = new Promise(
291✔
840
            (res) => (this.selectionResolver = res)
291✔
841
        );
291✔
842
        let selectedItem: MenuItem | undefined;
291✔
843
        await this.optionsMenu.updateComplete;
291✔
844
        if (this.recentlyConnected) {
291✔
845
            // Work around for attach timing differences in Safari and Firefox.
4✔
846
            // Remove when refactoring to Menu passthrough wrapper.
4✔
847
            await new Promise((res) => requestAnimationFrame(() => res(true)));
4✔
848
            this.recentlyConnected = false;
4✔
849
        }
4✔
850
        this.menuItems.forEach((item) => {
288✔
851
            if (this.value === item.value && !item.disabled) {
1,321✔
852
                selectedItem = item;
70✔
853
            } else {
1,321✔
854
                item.selected = false;
1,251✔
855
            }
1,251✔
856
        });
288✔
857
        if (selectedItem) {
291✔
858
            selectedItem.selected = !!this.selects;
70✔
859
            this.selectedItem = selectedItem;
70✔
860
        } else {
288✔
861
            this.value = '';
218✔
862
            this.selectedItem = undefined;
218✔
863
        }
218✔
864
        if (this.open) {
291✔
865
            await this.optionsMenu.updateComplete;
86✔
866
            this.optionsMenu.updateSelectedItemIndex();
86✔
867
        }
86✔
868
        this.selectionResolver();
288✔
869
        this.willManageSelection = false;
288✔
870
    }
552✔
871

408✔
872
    private selectionPromise = Promise.resolve();
408✔
873
    private selectionResolver!: () => void;
408✔
874

408✔
875
    protected override async getUpdateComplete(): Promise<boolean> {
408✔
876
        const complete = (await super.getUpdateComplete()) as boolean;
2,856✔
877
        await this.selectionPromise;
2,856✔
878
        // if (this.overlayElement) {
2,856✔
879
        //     await this.overlayElement.updateComplete;
2,856✔
880
        // }
2,856✔
881
        return complete;
2,856✔
882
    }
2,856✔
883

408✔
884
    private recentlyConnected = false;
408✔
885

408✔
886
    private enterKeydownOn: EventTarget | null = null;
408✔
887

408✔
888
    protected handleEnterKeydown = (event: KeyboardEvent): void => {
408✔
889
        if (event.key !== 'Enter') {
96✔
890
            return;
77✔
891
        }
77✔
892
        const target = event?.target as MenuItem;
96✔
893
        if (!target.open && target.hasSubmenu) {
96✔
894
            event.preventDefault();
1✔
895
            return;
1✔
896
        }
1✔
897

18✔
898
        if (this.enterKeydownOn) {
96✔
UNCOV
899
            event.preventDefault();
×
UNCOV
900
            return;
×
UNCOV
901
        }
✔
902
        this.enterKeydownOn = event.target;
18✔
903
        this.addEventListener(
18✔
904
            'keyup',
18✔
905
            async (keyupEvent: KeyboardEvent) => {
18✔
906
                if (keyupEvent.key !== 'Enter') {
18✔
UNCOV
907
                    return;
×
UNCOV
908
                }
×
909
                this.enterKeydownOn = null;
18✔
910
            },
18✔
911
            { once: true }
18✔
912
        );
18✔
913
    };
96✔
914

29✔
915
    public bindEvents(): void {
29✔
916
        this.strategy?.abort();
412✔
917
        if (this.isMobile.matches) {
412✔
918
            this.strategy = new strategies['mobile'](this.button, this);
4✔
919
        } else {
412✔
920
            this.strategy = new strategies['desktop'](this.button, this);
408✔
921
        }
408✔
922
    }
412✔
923

29✔
924
    public override connectedCallback(): void {
29✔
925
        super.connectedCallback();
414✔
926
        this.updateComplete.then(() => {
414✔
927
            if (!this.tooltipEl?.selfManaged) {
414✔
928
                return;
236✔
929
            }
236✔
930
            const overlayElement = this.tooltipEl.overlayElement;
178✔
931
            if (overlayElement) {
178✔
932
                overlayElement.triggerElement = this.button;
178✔
933
            }
178✔
934
        });
414✔
935

414✔
936
        this.recentlyConnected = this.hasUpdated;
414✔
937
        this.addEventListener('focus', this.handleFocus);
414✔
938
    }
414✔
939

29✔
940
    public override disconnectedCallback(): void {
29✔
941
        this.close();
414✔
942
        this.strategy?.releaseDescription();
414!
943
        super.disconnectedCallback();
414✔
944
    }
414✔
945
}
29✔
946

29✔
947
/**
29✔
948
 * @element sp-picker
29✔
949
 *
29✔
950
 * @slot label - The placeholder content for the Picker
29✔
951
 * @slot description - The description content for the Picker
29✔
952
 * @slot tooltip - Tooltip to to be applied to the the Picker Button
29✔
953
 * @slot - menu items to be listed in the Picker
29✔
954
 * @fires change - Announces that the `value` of the element has changed
29✔
955
 * @fires sp-opened - Announces that the overlay has been opened
29✔
956
 * @fires sp-closed - Announces that the overlay has been closed
29✔
957
 */
29✔
958
export class Picker extends PickerBase {
29✔
959
    public static override get styles(): CSSResultArray {
139✔
960
        return [pickerStyles, chevronStyles];
139✔
961
    }
139✔
962

139✔
963
    protected override get containerStyles(): StyleInfo {
139✔
964
        const styles = super.containerStyles;
292✔
965
        if (!this.quiet) {
292✔
966
            styles['min-width'] = `${this.offsetWidth}px`;
292✔
967
        }
292✔
968
        return styles;
292✔
969
    }
292✔
970

139✔
971
    protected override handleKeydown = (event: KeyboardEvent): void => {
139✔
972
        const { key } = event;
56✔
973
        const handledKeys = [
56✔
974
            'ArrowUp',
56✔
975
            'ArrowDown',
56✔
976
            'ArrowLeft',
56✔
977
            'ArrowRight',
56✔
978
            'Enter',
56✔
979
            ' ',
56✔
980
            'Escape',
56✔
981
        ].includes(key);
56✔
982
        const openKeys = ['ArrowUp', 'ArrowDown', 'Enter', ' '].includes(key);
56✔
983
        this.focused = true;
56✔
984
        if ('Escape' === key) {
56✔
985
            this.handleEscape(event);
2✔
986
            return;
2✔
987
        }
2✔
988
        if (!handledKeys || this.readonly || this.pending) {
56✔
989
            return;
18✔
990
        }
18✔
991
        if (openKeys) {
56✔
992
            this.keyboardOpen();
20✔
993
            event.preventDefault();
20✔
994
            return;
20✔
995
        }
20✔
996
        event.preventDefault();
16✔
997
        const nextItem = this.optionsMenu?.getNeighboringFocusableElement(
56✔
998
            this.selectedItem,
16✔
999
            key === 'ArrowLeft'
16✔
1000
        );
56✔
1001
        if (!this.value || nextItem !== this.selectedItem) {
56✔
1002
            // updates picker text but does not fire change event until action is completed
12✔
1003
            if (!!nextItem) this.setValueFromItem(nextItem as MenuItem);
12✔
1004
        }
12✔
1005
    };
56✔
1006
}
29✔
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