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

adobe / spectrum-web-components / 13553164764

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

Pull #5031

github

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

5295 of 5602 branches covered (94.52%)

Branch coverage included in aggregate %.

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

27 existing lines in 8 files now uncovered.

33662 of 34164 relevant lines covered (98.53%)

644.21 hits per line

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

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

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

30✔
13
import {
30✔
14
    CSSResultArray,
30✔
15
    DefaultElementSize,
30✔
16
    html,
30✔
17
    nothing,
30✔
18
    PropertyValues,
30✔
19
    render,
30✔
20
    SizedMixin,
30✔
21
    SpectrumElement,
30✔
22
    TemplateResult,
30✔
23
} from '@spectrum-web-components/base';
30✔
24
import {
30✔
25
    classMap,
30✔
26
    ifDefined,
30✔
27
    StyleInfo,
30✔
28
    styleMap,
30✔
29
} from '@spectrum-web-components/base/src/directives.js';
30✔
30
import {
30✔
31
    property,
30✔
32
    query,
30✔
33
    state,
30✔
34
} from '@spectrum-web-components/base/src/decorators.js';
30✔
35

30✔
36
import pickerStyles from './picker.css.js';
30✔
37
import chevronStyles from '@spectrum-web-components/icon/src/spectrum-icon-chevron.css.js';
30✔
38

30✔
39
import type { Tooltip } from '@spectrum-web-components/tooltip';
30✔
40
import '@spectrum-web-components/icons-ui/icons/sp-icon-chevron100.js';
30✔
41
import '@spectrum-web-components/icons-workflow/icons/sp-icon-alert.js';
30✔
42
import '@spectrum-web-components/menu/sp-menu.js';
30✔
43
import type {
30✔
44
    Menu,
30✔
45
    MenuItem,
30✔
46
    MenuItemChildren,
30✔
47
} from '@spectrum-web-components/menu';
30✔
48

30✔
49
import type { MenuItemKeydownEvent } from '@spectrum-web-components/menu';
30✔
50
import { Placement } from '@spectrum-web-components/overlay';
30✔
51
import {
30✔
52
    IS_MOBILE,
30✔
53
    MatchMediaController,
30✔
54
} from '@spectrum-web-components/reactive-controllers/src/MatchMedia.js';
30✔
55
import { DependencyManagerController } from '@spectrum-web-components/reactive-controllers/src/DependencyManger.js';
30✔
56
import { PendingStateController } from '@spectrum-web-components/reactive-controllers/src/PendingState.js';
30✔
57
import { Overlay } from '@spectrum-web-components/overlay/src/Overlay.js';
30✔
58
import type { SlottableRequestEvent } from '@spectrum-web-components/overlay/src/slottable-request-event.js';
30✔
59
import type { FieldLabel } from '@spectrum-web-components/field-label';
30✔
60

30✔
61
import { DesktopController } from './DesktopController.js';
30✔
62
import { MobileController } from './MobileController.js';
30✔
63
import { strategies } from './strategies.js';
30✔
64

30✔
65
const chevronClass = {
30✔
66
    s: 'spectrum-UIIcon-ChevronDown75',
30✔
67
    m: 'spectrum-UIIcon-ChevronDown100',
30✔
68
    l: 'spectrum-UIIcon-ChevronDown200',
30✔
69
    xl: 'spectrum-UIIcon-ChevronDown300',
30✔
70
};
30✔
71

30✔
72
export const DESCRIPTION_ID = 'option-picker';
30✔
73
export class PickerBase extends SizedMixin(SpectrumElement, {
30✔
74
    noDefaultSize: true,
30✔
75
}) {
30✔
76
    static override shadowRootOptions = {
30✔
77
        ...SpectrumElement.shadowRootOptions,
30✔
78
        delegatesFocus: true,
30✔
79
    };
30✔
80

30✔
81
    public isMobile = new MatchMediaController(this, IS_MOBILE);
30✔
82

30✔
83
    public strategy!: DesktopController | MobileController;
30✔
84

30✔
85
    @state()
30✔
86
    appliedLabel?: string;
30✔
87

30✔
88
    @query('#button')
30✔
89
    public button!: HTMLButtonElement;
30✔
90

30✔
91
    public dependencyManager = new DependencyManagerController(this);
30✔
92

30✔
93
    private deprecatedMenu: Menu | null = null;
30✔
94

30✔
95
    @property({ type: Boolean, reflect: true })
30✔
96
    public disabled = false;
30✔
97

30✔
98
    @property({ type: Boolean, reflect: true })
30✔
99
    public focused = false;
30✔
100

30✔
101
    @property({ type: String, reflect: true })
30✔
102
    public icons?: 'only' | 'none';
30✔
103

30✔
104
    @property({ type: Boolean, reflect: true })
30✔
105
    public invalid = false;
30✔
106

30✔
107
    /**
30✔
108
     * Forces the Picker to render as a popover on mobile instead of a tray.
30✔
109
     *
30✔
110
     * @memberof PickerBase
30✔
111
     */
30✔
112
    @property({ type: Boolean, reflect: true, attribute: 'force-popover' })
30✔
113
    public forcePopover = false;
30✔
114

30✔
115
    /** Whether the items are currently loading. */
30✔
116
    @property({ type: Boolean, reflect: true })
30✔
117
    public pending = false;
30✔
118

30✔
119
    /** Defines a string value that labels the Picker while it is in pending state. */
30✔
120
    @property({ type: String, attribute: 'pending-label' })
30✔
121
    public pendingLabel = 'Pending';
30✔
122

30✔
123
    @property()
30✔
124
    public label?: string;
30✔
125

30✔
126
    @property({ type: Boolean, reflect: true })
30✔
127
    public open = false;
30✔
128

30✔
129
    @property({ type: Boolean, reflect: true })
30✔
130
    public readonly = false;
30✔
131

30✔
132
    public selects: undefined | 'single' = 'single';
30✔
133

30✔
134
    @state()
30✔
135
    public labelAlignment?: 'inline';
30✔
136

30✔
137
    protected get menuItems(): MenuItem[] {
30✔
138
        return this.optionsMenu.childItems;
30✔
139
    }
30✔
140

30✔
141
    @query('sp-menu')
30✔
142
    public optionsMenu!: Menu;
30✔
143

30✔
144
    /**
30✔
145
     * @deprecated
30✔
146
     * */
30✔
147
    public get selfManageFocusElement(): boolean {
30✔
NEW
148
        return true;
×
UNCOV
149
    }
×
150

30✔
151
    @query('sp-overlay')
30✔
152
    public overlayElement!: Overlay;
30✔
153

30✔
154
    protected tooltipEl?: Tooltip;
30✔
155

30✔
156
    /**
30✔
157
     * @type {"top" | "top-start" | "top-end" | "right" | "right-start" | "right-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end"}
30✔
158
     * @attr
30✔
159
     */
30✔
160

30✔
161
    @property()
30✔
162
    public placement: Placement = 'bottom-start';
30✔
163

30✔
164
    @property({ type: Boolean, reflect: true })
30✔
165
    public quiet = false;
30✔
166

30✔
167
    @property({ type: String })
30✔
168
    public value = '';
30✔
169

30✔
170
    @property({ attribute: false })
30✔
171
    public get selectedItem(): MenuItem | undefined {
30✔
172
        return this._selectedItem;
2,445✔
173
    }
2,445✔
174

30✔
175
    public pendingStateController: PendingStateController<this>;
30✔
176

30✔
177
    /**
30✔
178
     * Initializes the `PendingStateController` for the Picker component.
30✔
179
     * The `PendingStateController` manages the pending state of the Picker.
30✔
180
     */
30✔
181
    constructor() {
30✔
182
        super();
466✔
183
        this.pendingStateController = new PendingStateController(this);
466✔
184
    }
466✔
185

30✔
186
    public set selectedItem(selectedItem: MenuItem | undefined) {
30✔
187
        this.selectedItemContent = selectedItem
369✔
188
            ? selectedItem.itemChildren
137✔
189
            : undefined;
232✔
190

369✔
191
        if (selectedItem === this.selectedItem) return;
369✔
192
        const oldSelectedItem = this.selectedItem;
106✔
193
        this._selectedItem = selectedItem;
106✔
194
        this.requestUpdate('selectedItem', oldSelectedItem);
106✔
195
    }
369✔
196

30✔
197
    _selectedItem?: MenuItem;
30✔
198

30✔
199
    protected listRole: 'listbox' | 'menu' = 'listbox';
30✔
200
    protected itemRole = 'option';
30✔
201

30✔
202
    public get focusElement(): HTMLElement {
30✔
203
        if (this.open) {
415✔
204
            return this.optionsMenu;
24✔
205
        }
24✔
206
        return this.button;
391✔
207
    }
415✔
208

30✔
209
    public forceFocusVisible(): void {
30✔
210
        if (this.disabled) {
5✔
211
            return;
2✔
212
        }
2✔
213

3✔
214
        this.focused = true;
3✔
215
    }
5✔
216

30✔
217
    // handled by interaction controller, desktop or mobile; this is only called with a programmatic this.click()
30✔
218
    public override click(): void {
30✔
219
        this.toggle();
58✔
220
    }
58✔
221

30✔
222
    // pointer events handled by interaction controller, desktop or mobile; this is only called with a programmatic this.button.click()
30✔
223
    public handleButtonClick(): void {
30✔
UNCOV
224
        if (this.disabled) {
×
UNCOV
225
            return;
×
UNCOV
226
        }
×
UNCOV
227
        this.toggle();
×
UNCOV
228
    }
×
229

30✔
230
    public handleButtonBlur(): void {
30✔
231
        this.focused = false;
131✔
232
    }
131✔
233

30✔
234
    public override focus(options?: FocusOptions): void {
30✔
235
        this.focusElement?.focus(options);
45!
236
    }
45✔
237
    /**
30✔
238
     * @deprecated - Use `focus` instead.
30✔
239
     */
30✔
240
    public handleHelperFocus(): void {
30✔
UNCOV
241
        // set focused to true here instead of handleButtonFocus so clicks don't flash a focus outline
×
UNCOV
242
        this.focused = true;
×
UNCOV
243
        this.button.focus();
×
UNCOV
244
    }
×
245

30✔
246
    public handleFocus(): void {
30✔
247
        if (!this.disabled && this.focusElement) {
131✔
248
            this.focused = this.hasVisibleFocusInTree();
131✔
249
        }
131✔
250
    }
131✔
251

30✔
252
    public handleChange(event: Event): void {
30✔
253
        if (this.strategy) {
53✔
254
            this.strategy.preventNextToggle = 'no';
53✔
255
        }
53✔
256
        const target = event.target as Menu;
53✔
257
        const [selected] = target.selectedItems;
53✔
258
        event.stopPropagation();
53✔
259
        if (event.cancelable) {
53✔
260
            this.setValueFromItem(selected, event);
49✔
261
        } else {
53✔
262
            // Non-cancelable "change" events announce a selection with no value
4✔
263
            // change that should close the Picker element.
4✔
264
            this.open = false;
4✔
265
            if (this.strategy) {
4✔
266
                this.strategy.open = false;
4✔
267
            }
4✔
268
        }
4✔
269
    }
53✔
270

30✔
271
    public handleButtonFocus(event: FocusEvent): void {
30✔
272
        this.strategy?.handleButtonFocus(event);
131!
273
    }
131✔
274

30✔
275
    protected handleEscape = (
30✔
276
        event: MenuItemKeydownEvent | KeyboardEvent
47✔
277
    ): void => {
47✔
278
        if (event.key === 'Escape') {
47✔
279
            event.stopPropagation();
21✔
280
            event.preventDefault();
21✔
281
            this.toggle(false);
21✔
282
        }
21✔
283
    };
47✔
284

30✔
285
    protected handleKeydown = (event: KeyboardEvent): void => {
30✔
286
        this.focused = true;
20✔
287
        if (
20✔
288
            !['ArrowUp', 'ArrowDown', 'Enter', ' ', 'Escape'].includes(
20✔
289
                event.key
20✔
290
            )
20✔
291
        ) {
20!
292
            return;
10✔
293
        }
10✔
294
        if (event.key === 'Escape') {
20!
295
            this.handleEscape(event);
10✔
296
            return;
10✔
297
        }
10✔
298
        event.stopPropagation();
20✔
299
        event.preventDefault();
20✔
300
        this.keyboardOpen();
20✔
301
    };
20✔
302

30✔
303
    protected async keyboardOpen(): Promise<void> {
30✔
304
        this.toggle(true);
32✔
305
    }
32✔
306

30✔
307
    protected async setValueFromItem(
30✔
308
        item: MenuItem,
61✔
309
        menuChangeEvent?: Event
61✔
310
    ): Promise<void> {
61✔
311
        this.open = false;
61✔
312
        // should always close when "setting" a value
61✔
313
        if (this.strategy) {
61✔
314
            this.strategy.open = false;
61✔
315
        }
61✔
316
        const oldSelectedItem = this.selectedItem;
61✔
317
        const oldValue = this.value;
61✔
318

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

30✔
358
    protected setMenuItemSelected(item: MenuItem, value: boolean): void {
30✔
359
        // matches null | undefined
55✔
360
        if (this.selects == null) return;
55!
361
        item.selected = value;
55✔
362
    }
55✔
363

30✔
364
    public toggle(target?: boolean): void {
30✔
365
        if (this.readonly || this.pending || this.disabled) {
128✔
366
            return;
6✔
367
        }
6✔
368
        const open = typeof target !== 'undefined' ? target : !this.open;
128✔
369
        if (open && !this.open)
128✔
370
            this.addEventListener(
128✔
371
                'sp-opened',
105✔
372
                () => this.optionsMenu?.focusOnFirstSelectedItem(),
105✔
373
                {
105✔
374
                    once: true,
105✔
375
                }
105✔
376
            );
105✔
377

122✔
378
        this.open = open;
122✔
379
        if (this.strategy) {
122✔
380
            this.strategy.open = this.open;
122✔
381
        }
122✔
382
    }
128✔
383

30✔
384
    public close(): void {
30✔
385
        if (this.readonly) {
727✔
386
            return;
2✔
387
        }
2✔
388
        if (this.strategy) {
727✔
389
            this.open = false;
709✔
390
            this.strategy.open = false;
709✔
391
        }
709✔
392
    }
727✔
393

30✔
394
    protected get containerStyles(): StyleInfo {
30✔
395
        // @todo: test in mobile
478✔
396
        /* c8 ignore next 5 */
30✔
397
        if (this.isMobile.matches) {
30✔
398
            return {
30✔
399
                '--swc-menu-width': '100%',
30✔
400
            };
30✔
401
        }
30✔
402
        return {};
468✔
403
    }
478✔
404

30✔
405
    @state()
30✔
406
    protected get selectedItemContent(): MenuItemChildren {
30✔
407
        return this._selectedItemContent || { icon: [], content: [] };
4,465✔
408
    }
4,465✔
409

30✔
410
    protected set selectedItemContent(
30✔
411
        selectedItemContent: MenuItemChildren | undefined
369✔
412
    ) {
369✔
413
        if (selectedItemContent === this.selectedItemContent) return;
369✔
414

324✔
415
        const oldContent = this.selectedItemContent;
324✔
416
        this._selectedItemContent = selectedItemContent;
324✔
417
        this.requestUpdate('selectedItemContent', oldContent);
324✔
418
    }
369✔
419

30✔
420
    _selectedItemContent?: MenuItemChildren;
30✔
421

30✔
422
    protected handleTooltipSlotchange(
30✔
423
        event: Event & { target: HTMLSlotElement }
245✔
424
    ): void {
245✔
425
        this.tooltipEl = event.target.assignedElements()[0] as
245✔
426
            | Tooltip
245✔
427
            | undefined;
245✔
428
    }
245✔
429

30✔
430
    public handleSlottableRequest = (_event: SlottableRequestEvent): void => {};
30✔
431

30✔
432
    protected renderLabelContent(content: Node[]): TemplateResult | Node[] {
30✔
433
        if (this.value && this.selectedItem) {
889✔
434
            return content;
124✔
435
        }
124✔
436
        return html`
765✔
437
            <slot name="label" id="label">
765✔
438
                <span
765✔
439
                    aria-hidden=${ifDefined(
765✔
440
                        this.appliedLabel ? undefined : 'true'
889✔
441
                    )}
889✔
442
                >
889✔
443
                    ${this.label}
889✔
444
                </span>
889✔
445
            </slot>
889✔
446
        `;
889✔
447
    }
889✔
448

30✔
449
    protected get buttonContent(): TemplateResult[] {
30✔
450
        const labelClasses = {
889✔
451
            'visually-hidden': this.icons === 'only' && !!this.value,
889!
452
            placeholder: !this.value,
889✔
453
            label: true,
889✔
454
        };
889✔
455
        const appliedLabel = this.appliedLabel || this.label;
889✔
456
        return [
889✔
457
            html`
889✔
458
                <span id="icon" ?hidden=${this.icons === 'none'}>
889✔
459
                    ${this.selectedItemContent.icon}
889✔
460
                </span>
889✔
461
                <span
889✔
462
                    id=${ifDefined(
889✔
463
                        this.value && this.selectedItem ? 'label' : undefined
889✔
464
                    )}
889✔
465
                    class=${classMap(labelClasses)}
889✔
466
                >
889✔
467
                    ${this.renderLabelContent(this.selectedItemContent.content)}
889✔
468
                </span>
889✔
469
                ${this.value && this.selectedItem
889✔
470
                    ? html`
124✔
471
                          <span
124✔
472
                              aria-hidden="true"
124✔
473
                              class="visually-hidden"
124✔
474
                              id="applied-label"
124✔
475
                          >
124✔
476
                              ${appliedLabel}
124✔
477
                              <slot name="label"></slot>
765✔
478
                          </span>
765✔
479
                      `
765✔
480
                    : html`
765✔
481
                          <span hidden id="applied-label">${appliedLabel}</span>
765✔
482
                      `}
889✔
483
                ${this.invalid && !this.pending
889✔
484
                    ? html`
2✔
485
                          <sp-icon-alert
887✔
486
                              class="validation-icon"
887✔
487
                          ></sp-icon-alert>
887✔
488
                      `
887✔
489
                    : nothing}
889✔
490
                ${this.pendingStateController.renderPendingState()}
889✔
491
                <sp-icon-chevron100
889✔
492
                    class="picker ${chevronClass[
889✔
493
                        this.size as DefaultElementSize
889✔
494
                    ]}"
889✔
495
                ></sp-icon-chevron100>
889✔
496
                <slot
889✔
497
                    aria-hidden="true"
889✔
498
                    name="tooltip"
889✔
499
                    id="tooltip"
889✔
500
                    @keydown=${this.handleKeydown}
889✔
501
                    @slotchange=${this.handleTooltipSlotchange}
889✔
502
                ></slot>
889✔
503
            `,
889✔
504
        ];
889✔
505
    }
889✔
506

30✔
507
    applyFocusElementLabel = (
30✔
508
        value: string,
242✔
509
        labelElement: FieldLabel
242✔
510
    ): void => {
242✔
511
        this.appliedLabel = value;
242✔
512
        this.labelAlignment = labelElement.sideAligned ? 'inline' : undefined;
242!
513
    };
242✔
514

30✔
515
    protected hasAccessibleLabel(): boolean {
30✔
516
        const slotContent =
881✔
517
            this.querySelector('[slot="label"]')?.textContent &&
881✔
518
            this.querySelector('[slot="label"]')?.textContent?.trim() !== '';
14!
519
        const slotAlt =
881✔
520
            this.querySelector('[slot="label"]')?.getAttribute('alt')?.trim() &&
881!
NEW
521
            this.querySelector('[slot="label"]')
×
NEW
522
                ?.getAttribute('alt')
×
NEW
523
                ?.trim() !== '';
×
524
        return (
881✔
525
            !!this.label ||
881✔
526
            !!this.getAttribute('aria-label') ||
700✔
527
            !!this.getAttribute('aria-labelledby') ||
685✔
528
            !!this.appliedLabel ||
685✔
529
            !!slotContent ||
29✔
530
            !!slotAlt
21✔
531
        );
881✔
532
    }
881✔
533

30✔
534
    protected warnNoLabel(): void {
30✔
535
        window.__swc.warn(
21✔
536
            this,
21✔
537
            `<${this.localName}> needs one of the following to be accessible:`,
21✔
538
            'https://opensource.adobe.com/spectrum-web-components/components/picker/#accessibility',
21✔
539
            {
21✔
540
                type: 'accessibility',
21✔
541
                issues: [
21✔
542
                    `an <sp-field-label> element with a \`for\` attribute referencing the \`id\` of the \`<${this.localName}>\`, or`,
21✔
543
                    'value supplied to the "label" attribute, which will be displayed visually as placeholder text, or',
21✔
544
                    'text content supplied in a <span> with slot="label", which will also be displayed visually as placeholder text.',
21✔
545
                ],
21✔
546
            }
21✔
547
        );
21✔
548
    }
21✔
549

30✔
550
    protected renderOverlay(menu: TemplateResult): TemplateResult {
30✔
551
        if (this.strategy?.overlay === undefined) {
705✔
552
            return menu;
227✔
553
        }
227✔
554
        const container = this.renderContainer(menu);
478✔
555
        render(container, this.strategy?.overlay as unknown as HTMLElement, {
705!
556
            host: this,
705✔
557
        });
705✔
558
        return this.strategy?.overlay as unknown as TemplateResult;
705!
559
    }
705✔
560

30✔
561
    protected get renderDescriptionSlot(): TemplateResult {
30✔
562
        return html`
1,479✔
563
            <div id=${DESCRIPTION_ID}>
1,479✔
564
                <slot name="description"></slot>
1,479✔
565
            </div>
1,479✔
566
        `;
1,479✔
567
    }
1,479✔
568
    // a helper to throw focus to the button is needed because Safari
30✔
569
    // won't include buttons in the tab order even with tabindex="0"
30✔
570
    protected override render(): TemplateResult {
30✔
571
        if (this.tooltipEl) {
889✔
572
            this.tooltipEl.disabled = this.open;
16✔
573
        }
16✔
574
        return html`
889✔
575
            <button
889✔
576
                aria-controls=${ifDefined(this.open ? 'menu' : undefined)}
889✔
577
                aria-describedby="tooltip ${DESCRIPTION_ID}"
889✔
578
                aria-expanded=${this.open ? 'true' : 'false'}
889✔
579
                aria-haspopup="true"
889✔
580
                aria-labelledby="loader icon label applied-label"
889✔
581
                id="button"
889✔
582
                class=${ifDefined(
889✔
583
                    this.labelAlignment
889!
584
                        ? `label-${this.labelAlignment}`
×
585
                        : undefined
889✔
586
                )}
889✔
587
                @focus=${this.handleButtonFocus}
889✔
588
                @blur=${this.handleButtonBlur}
889✔
589
                @keydown=${{
889✔
590
                    handleEvent: this.handleEnterKeydown,
889✔
591
                    capture: true,
889✔
592
                }}
889✔
593
                ?disabled=${this.disabled}
889✔
594
            >
889✔
595
                ${this.buttonContent}
889✔
596
            </button>
889✔
597
            ${this.renderMenu} ${this.renderDescriptionSlot}
889✔
598
        `;
889✔
599
    }
889✔
600

30✔
601
    protected override willUpdate(changes: PropertyValues<this>): void {
30✔
602
        super.willUpdate(changes);
1,479✔
603
        if (changes.has('tabIndex') && !!this.tabIndex) {
1,479!
NEW
604
            this.button.tabIndex = this.tabIndex;
×
NEW
605
            this.removeAttribute('tabindex');
×
NEW
606
        }
×
607
    }
1,479✔
608

30✔
609
    protected override update(changes: PropertyValues<this>): void {
30✔
610
        if (this.selects) {
1,479✔
611
            /**
916✔
612
             * Always force `selects` to "single" when set.
916✔
613
             *
916✔
614
             * @todo: Add support functionally and visually for "multiple"
916✔
615
             */
916✔
616
            this.selects = 'single';
916✔
617
        }
916✔
618
        if (changes.has('disabled') && this.disabled) {
1,479✔
619
            this.close();
9✔
620
        }
9✔
621
        if (changes.has('pending') && this.pending) {
1,479✔
622
            this.close();
14✔
623
        }
14✔
624
        if (changes.has('value')) {
1,479✔
625
            // MenuItems update a frame late for <slot> management,
582✔
626
            // await the same here.
582✔
627
            this.shouldScheduleManageSelection();
582✔
628
        }
582✔
629
        // Maybe it's finally time to remove this support?s
1,479✔
630
        if (!this.hasUpdated) {
1,479✔
631
            this.deprecatedMenu = this.querySelector(':scope > sp-menu');
466✔
632
            this.deprecatedMenu?.toggleAttribute('ignore', true);
466✔
633
            this.deprecatedMenu?.setAttribute('selects', 'inherit');
466✔
634
        }
466✔
635
        if (window.__swc.DEBUG) {
1,479✔
636
            if (!this.hasUpdated && this.querySelector(':scope > sp-menu')) {
1,479✔
637
                const { localName } = this;
7✔
638
                window.__swc.warn(
7✔
639
                    this,
7✔
640
                    `You no longer need to provide an <sp-menu> child to ${localName}. Any styling or attributes on the <sp-menu> will be ignored.`,
7✔
641
                    'https://opensource.adobe.com/spectrum-web-components/components/picker/#sizes',
7✔
642
                    { level: 'deprecation' }
7✔
643
                );
7✔
644
            }
7✔
645
            this.updateComplete.then(async () => {
1,479✔
646
                // Attributes should be user supplied, making them available before first update.
1,479✔
647
                // However, `appliesLabel` is applied by external elements that must be update complete as well to be bound appropriately.
1,479✔
648
                await new Promise((res) => requestAnimationFrame(res));
1,479✔
649
                await new Promise((res) => requestAnimationFrame(res));
1,473✔
650
                if (!this.hasAccessibleLabel()) {
1,479✔
651
                    this.warnNoLabel();
21✔
652
                }
21✔
653
            });
1,479✔
654
        }
1,479✔
655
        super.update(changes);
1,479✔
656
    }
1,479✔
657

30✔
658
    protected bindButtonKeydownListener(): void {
30✔
659
        this.button.addEventListener('keydown', this.handleKeydown);
466✔
660
    }
466✔
661

30✔
662
    protected override updated(changes: PropertyValues<this>): void {
30✔
663
        super.updated(changes);
1,479✔
664
        if (changes.has('open')) {
1,479✔
665
            this.strategy.open = this.open;
742✔
666
        }
742✔
667
    }
1,479✔
668

30✔
669
    protected override firstUpdated(changes: PropertyValues<this>): void {
30✔
670
        super.firstUpdated(changes);
466✔
671
        this.bindButtonKeydownListener();
466✔
672
        this.bindEvents();
466✔
673
    }
466✔
674

30✔
675
    protected get dismissHelper(): TemplateResult {
30✔
676
        return html`
956✔
677
            <div class="visually-hidden">
956✔
678
                <button
956✔
679
                    tabindex="-1"
956✔
680
                    aria-label="Dismiss"
956✔
681
                    @click=${this.close}
956✔
682
                ></button>
956✔
683
            </div>
956✔
684
        `;
956✔
685
    }
956✔
686

30✔
687
    protected renderContainer(menu: TemplateResult): TemplateResult {
30✔
688
        const accessibleMenu = html`
478✔
689
            ${this.dismissHelper} ${menu} ${this.dismissHelper}
478✔
690
        `;
478✔
691
        // @todo: test in mobile
478✔
692
        if (this.isMobile.matches && !this.forcePopover) {
478✔
693
            this.dependencyManager.add('sp-tray');
5✔
694
            import('@spectrum-web-components/tray/sp-tray.js');
5✔
695
            return html`
5✔
696
                <sp-tray
5✔
697
                    id="popover"
5✔
698
                    role="presentation"
5✔
699
                    style=${styleMap(this.containerStyles)}
5✔
700
                >
5✔
701
                    ${accessibleMenu}
5✔
702
                </sp-tray>
5✔
703
            `;
5✔
704
        }
5✔
705
        this.dependencyManager.add('sp-popover');
473✔
706
        import('@spectrum-web-components/popover/sp-popover.js');
473✔
707
        return html`
473✔
708
            <sp-popover
473✔
709
                id="popover"
473✔
710
                role="presentation"
473✔
711
                style=${styleMap(this.containerStyles)}
473✔
712
                placement=${this.placement}
473✔
713
            >
473✔
714
                ${accessibleMenu}
473✔
715
            </sp-popover>
478✔
716
        `;
478✔
717
    }
478✔
718

30✔
719
    protected hasRenderedOverlay = false;
30✔
720

30✔
721
    private onScroll(): void {
30✔
722
        this.dispatchEvent(
4✔
723
            new Event('scroll', {
4✔
724
                cancelable: true,
4✔
725
                composed: true,
4✔
726
            })
4✔
727
        );
4✔
728
    }
4✔
729

30✔
730
    protected get renderMenu(): TemplateResult {
30✔
731
        const menu = html`
1,479✔
732
            <sp-menu
1,479✔
733
                aria-labelledby="applied-label"
1,479✔
734
                @change=${this.handleChange}
1,479✔
735
                id="menu"
1,479✔
736
                @keydown=${{
1,479✔
737
                    handleEvent: this.handleEnterKeydown,
1,479✔
738
                    capture: true,
1,479✔
739
                }}
1,479✔
740
                @scroll=${this.onScroll}
1,479✔
741
                role=${this.listRole}
1,479✔
742
                .selects=${this.selects}
1,479✔
743
                .selected=${this.value ? [this.value] : []}
1,479✔
744
                size=${this.size}
1,479✔
745
                @sp-menu-item-keydown=${this.handleEscape}
1,479✔
746
                @sp-menu-item-added-or-updated=${this.shouldManageSelection}
1,479✔
747
            >
1,479✔
748
                <slot @slotchange=${this.shouldScheduleManageSelection}></slot>
1,479✔
749
            </sp-menu>
1,479✔
750
        `;
1,479✔
751
        this.hasRenderedOverlay =
1,479✔
752
            this.hasRenderedOverlay ||
1,479✔
753
            this.focused ||
904✔
754
            this.open ||
857✔
755
            !!this.deprecatedMenu;
781✔
756
        if (this.hasRenderedOverlay) {
1,479✔
757
            if (this.dependencyManager.loaded) {
705✔
758
                this.dependencyManager.add('sp-overlay');
362✔
759
            }
362✔
760
            return this.renderOverlay(menu);
705✔
761
        }
705✔
762
        return menu;
774✔
763
    }
1,479✔
764

30✔
765
    /**
30✔
766
     * whether a selection change is already scheduled
30✔
767
     */
30✔
768
    public willManageSelection = false;
30✔
769

30✔
770
    /**
30✔
771
     * when the value changes or the menu slot changes, manage the selection on the next frame, if not already scheduled
30✔
772
     * @param event
30✔
773
     */
30✔
774
    protected shouldScheduleManageSelection(event?: Event): void {
30✔
775
        if (
1,296✔
776
            !this.willManageSelection &&
1,296✔
777
            (!event ||
658✔
778
                ((event.target as HTMLElement).getRootNode() as ShadowRoot)
122✔
779
                    .host === this)
122✔
780
        ) {
1,296✔
781
            //s set a flag to manage selection on the next frame
598✔
782
            this.willManageSelection = true;
598✔
783
            requestAnimationFrame(() => {
598✔
784
                requestAnimationFrame(() => {
598✔
785
                    this.manageSelection();
596✔
786
                });
598✔
787
            });
598✔
788
        }
598✔
789
    }
1,296✔
790

30✔
791
    /**
30✔
792
     * when an item is added or updated, manage the selection, if it's not already scheduled
30✔
793
     */
30✔
794
    protected shouldManageSelection(): void {
30✔
795
        if (this.willManageSelection) {
3,007✔
796
            return;
2,994✔
797
        }
2,994✔
798
        this.willManageSelection = true;
13✔
799
        this.manageSelection();
13✔
800
    }
3,007✔
801

30✔
802
    /**
30✔
803
     * updates menu selection based on value
30✔
804
     */
30✔
805
    protected async manageSelection(): Promise<void> {
30✔
806
        if (this.selects == null) return;
609!
807

289✔
808
        this.selectionPromise = new Promise(
289✔
809
            (res) => (this.selectionResolver = res)
289✔
810
        );
289✔
811
        let selectedItem: MenuItem | undefined;
289✔
812
        await this.optionsMenu.updateComplete;
289✔
813
        if (this.recentlyConnected) {
289✔
814
            // Work around for attach timing differences in Safari and Firefox.
4✔
815
            // Remove when refactoring to Menu passthrough wrapper.
4✔
816
            await new Promise((res) => requestAnimationFrame(() => res(true)));
4✔
817
            this.recentlyConnected = false;
4✔
818
        }
4✔
819
        this.menuItems.forEach((item) => {
286✔
820
            if (this.value === item.value && !item.disabled) {
1,396✔
821
                selectedItem = item;
74✔
822
            } else {
1,396✔
823
                item.selected = false;
1,322✔
824
            }
1,322✔
825
        });
286✔
826
        if (selectedItem) {
289✔
827
            selectedItem.selected = !!this.selects;
74✔
828
            this.selectedItem = selectedItem;
74✔
829
        } else {
286✔
830
            this.value = '';
212✔
831
            this.selectedItem = undefined;
212✔
832
        }
212✔
833
        if (this.open) {
289✔
834
            await this.optionsMenu.updateComplete;
83✔
835
            this.optionsMenu.updateSelectedItemIndex();
83✔
836
        }
83✔
837
        this.selectionResolver();
286✔
838
        this.willManageSelection = false;
286✔
839
    }
609✔
840

30✔
841
    private selectionPromise = Promise.resolve();
30✔
842
    private selectionResolver!: () => void;
30✔
843

30✔
844
    protected override async getUpdateComplete(): Promise<boolean> {
30✔
845
        const complete = (await super.getUpdateComplete()) as boolean;
2,389✔
846
        await this.selectionPromise;
2,389✔
847
        // if (this.overlayElement) {
2,389✔
848
        //     await this.overlayElement.updateComplete;
2,389✔
849
        // }
2,389✔
850
        return complete;
2,389✔
851
    }
2,389✔
852

30✔
853
    private recentlyConnected = false;
30✔
854

30✔
855
    private enterKeydownOn: EventTarget | null = null;
30✔
856

30✔
857
    protected handleEnterKeydown = (event: KeyboardEvent): void => {
30✔
858
        if (event.key !== 'Enter') {
113✔
859
            return;
93✔
860
        }
93✔
861
        const target = event?.target as MenuItem;
113!
862
        if (!target.open && target.hasSubmenu) {
113✔
863
            event.preventDefault();
11✔
864
            return;
11✔
865
        }
11✔
866

29✔
867
        if (this.enterKeydownOn) {
112!
868
            event.preventDefault();
10✔
869
            return;
10✔
870
        }
10✔
871
        this.enterKeydownOn = event.target;
29✔
872
        this.addEventListener(
29✔
873
            'keyup',
29✔
874
            async (keyupEvent: KeyboardEvent) => {
29✔
875
                if (keyupEvent.key !== 'Enter') {
29!
876
                    return;
10✔
877
                }
10✔
878
                this.enterKeydownOn = null;
29✔
879
            },
29✔
880
            { once: true }
29✔
881
        );
29✔
882
    };
113✔
883

30✔
884
    public bindEvents(): void {
30✔
885
        this.strategy?.abort();
470✔
886
        if (this.isMobile.matches) {
470✔
887
            this.strategy = new strategies['mobile'](this.button, this);
4✔
888
        } else {
470✔
889
            this.strategy = new strategies['desktop'](this.button, this);
466✔
890
        }
466✔
891
    }
470✔
892

30✔
893
    public override connectedCallback(): void {
30✔
894
        super.connectedCallback();
472✔
895
        this.recentlyConnected = this.hasUpdated;
472✔
896
        this.addEventListener('focus', this.handleFocus);
472✔
897
    }
472✔
898

30✔
899
    public override disconnectedCallback(): void {
30✔
900
        this.close();
472✔
901
        this.strategy?.releaseDescription();
472!
902
        super.disconnectedCallback();
472✔
903
    }
472✔
904
}
30✔
905

30✔
906
/**
30✔
907
 * @element sp-picker
30✔
908
 *
30✔
909
 * @slot label - The placeholder content for the Picker
30✔
910
 * @slot description - The description content for the Picker
30✔
911
 * @slot tooltip - Tooltip to to be applied to the the Picker Button
30✔
912
 * @slot - menu items to be listed in the Picker
30✔
913
 * @fires change - Announces that the `value` of the element has changed
30✔
914
 * @fires sp-opened - Announces that the overlay has been opened
30✔
915
 * @fires sp-closed - Announces that the overlay has been closed
30✔
916
 */
30✔
917
export class Picker extends PickerBase {
30✔
918
    public static override get styles(): CSSResultArray {
139✔
919
        return [pickerStyles, chevronStyles];
139✔
920
    }
139✔
921

139✔
922
    protected override get containerStyles(): StyleInfo {
139✔
923
        const styles = super.containerStyles;
299✔
924
        if (!this.quiet) {
299✔
925
            styles['min-width'] = `${this.offsetWidth}px`;
299✔
926
        }
299✔
927
        return styles;
299✔
928
    }
299✔
929

139✔
930
    protected override handleKeydown = (event: KeyboardEvent): void => {
139✔
931
        const { key } = event;
56✔
932
        const handledKeys = [
56✔
933
            'ArrowUp',
56✔
934
            'ArrowDown',
56✔
935
            'ArrowLeft',
56✔
936
            'ArrowRight',
56✔
937
            'Enter',
56✔
938
            ' ',
56✔
939
            'Escape',
56✔
940
        ].includes(key);
56✔
941
        const openKeys = ['ArrowUp', 'ArrowDown', 'Enter', ' '].includes(key);
56✔
942
        this.focused = true;
56✔
943
        if ('Escape' === key) {
56✔
NEW
944
            this.handleEscape(event);
×
UNCOV
945
            return;
×
UNCOV
946
        }
×
947
        if (!handledKeys || this.readonly || this.pending) {
56✔
948
            return;
18✔
949
        }
18✔
950
        if (openKeys) {
56✔
951
            this.keyboardOpen();
22✔
952
            event.preventDefault();
22✔
953
            return;
22✔
954
        }
22✔
955
        event.preventDefault();
16✔
956
        const nextItem = this.optionsMenu?.getNeighboringFocusableElement(
56✔
957
            this.selectedItem,
16✔
958
            key === 'ArrowLeft'
16✔
959
        );
56✔
960
        if (!this.value || nextItem !== this.selectedItem) {
56✔
961
            // updates picker text but does not fire change event until action is completed
12✔
962
            if (!!nextItem) this.setValueFromItem(nextItem as MenuItem);
12✔
963
        }
12✔
964
    };
56✔
965
}
30✔
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