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

adobe / spectrum-web-components / 13552846816

26 Feb 2025 08:32PM UTC coverage: 97.963% (-0.2%) from 98.185%
13552846816

Pull #5031

github

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

5292 of 5600 branches covered (94.5%)

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%)

642.17 hits per line

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

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

95✔
7
Unless required by applicable law or agreed to in writing, software distributed under
95✔
8
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
95✔
9
OF ANY KIND, either express or implied. See the License for the specific language
95✔
10
governing permissions and limitations under the License.
95✔
11
*/
95✔
12
import { PropertyValues, SpectrumElement } from '@spectrum-web-components/base';
95✔
13
import { property } from '@spectrum-web-components/base/src/decorators.js';
95✔
14

95✔
15
import { FocusVisiblePolyfillMixin } from './focus-visible.js';
95✔
16

95✔
17
type DisableableElement = HTMLElement & { disabled?: boolean };
95✔
18

95✔
19
function nextFrame(): Promise<void> {
4✔
20
    return new Promise((res) => requestAnimationFrame(() => res()));
4✔
21
}
4✔
22

95✔
23
/**
95✔
24
 * Focusable base class handles tabindex setting into shadowed elements automatically.
95✔
25
 *
95✔
26
 * This implementation is based heavily on the aybolit delegate-focus-mixin at
95✔
27
 * https://github.com/web-padawan/aybolit/blob/master/packages/core/src/mixins/delegate-focus-mixin.js
95✔
28
 */
95✔
29
export class Focusable extends FocusVisiblePolyfillMixin(SpectrumElement) {
95✔
30
    /**
8,099✔
31
     * Disable this control. It will not receive focus or events
8,099✔
32
     */
8,099✔
33
    @property({ type: Boolean, reflect: true })
8,099✔
34
    public disabled = false;
8,099✔
35

8,099✔
36
    /**
8,099✔
37
     * When this control is rendered, focus it automatically
8,099✔
38
     * @private
8,099✔
39
     */
8,099✔
40
    @property({ type: Boolean })
8,099✔
41
    public override autofocus = false;
8,099✔
42

8,099✔
43
    /**
8,099✔
44
     * The tab index to apply to this control. See general documentation about
8,099✔
45
     * the tabindex HTML property
8,099✔
46
     *
8,099✔
47
     * @private
8,099✔
48
     */
8,099✔
49
    @property({ type: Number })
8,099✔
50
    public override get tabIndex(): number {
8,099✔
51
        if (this.focusElement === this) {
8,099✔
52
            const tabindex = this.hasAttribute('tabindex')
22,943✔
53
                ? Number(this.getAttribute('tabindex'))
37,669✔
54
                : NaN;
13,964✔
55
            return !isNaN(tabindex) ? tabindex : -1;
22,943✔
56
        }
22,943✔
57
        const tabIndexAttribute = parseFloat(
1,914✔
58
            this.hasAttribute('tabindex')
1,914✔
59
                ? (this.getAttribute('tabindex') as string) || '0'
768!
60
                : '0'
1,432✔
61
        );
8,099✔
62
        // When `disabled` tabindex is -1.
8,099✔
63
        // When host tabindex -1, use that as the cache.
8,099✔
64
        if (this.disabled || tabIndexAttribute < 0) {
8,099✔
65
            return -1;
791✔
66
        }
791✔
67
        // When `focusElement` isn't available yet,
1,361✔
68
        // use host tabindex as the cache.
1,361✔
69
        if (!this.focusElement) {
1,004✔
70
            return tabIndexAttribute;
1,180✔
71
        }
1,180✔
72
        // All other times, use the tabindex of `focusElement`
229✔
73
        // as the cache for this value.
229✔
74
        // return this.focusElement.tabIndex;
229✔
75
        return this._tabIndex;
229✔
76
    }
8,099✔
77
    public override set tabIndex(tabIndex: number) {
8,099✔
78
        // Flipping `manipulatingTabindex` to true before a change
18,942✔
79
        // allows for that change NOT to effect the cached value of tabindex
18,942✔
80
        if (this.manipulatingTabindex) {
18,942✔
81
            this.manipulatingTabindex = false;
6,965✔
82
            return;
6,965✔
83
        }
6,965✔
84

11,977✔
85
        if (this.focusElement === this) {
12,194✔
86
            if (this.disabled) {
11,697✔
87
                this._tabIndex = tabIndex;
1,044✔
88
            } else if (tabIndex !== this._tabIndex) {
11,697✔
89
                this._tabIndex = tabIndex;
7,134✔
90
                const tabindex = '' + tabIndex;
7,134✔
91
                this.manipulatingTabindex = true;
7,134✔
92
                this.setAttribute('tabindex', tabindex);
7,134✔
93
            }
7,134✔
94
            return;
11,697✔
95
        }
11,697✔
96

280✔
97
        if (tabIndex === -1) {
496✔
98
            this.addEventListener(
163✔
99
                'pointerdown',
163✔
100
                this.onPointerdownManagementOfTabIndex
163✔
101
            );
163✔
102
        } else {
489✔
103
            // All code paths are about to address the host tabindex without side effect.
117✔
104
            this.manipulatingTabindex = true;
117✔
105
            this.removeEventListener(
117✔
106
                'pointerdown',
117✔
107
                this.onPointerdownManagementOfTabIndex
117✔
108
            );
117✔
109
        }
117✔
110

280✔
111
        if (tabIndex === -1 || this.disabled) {
18,942✔
112
            this.manipulatingTabindex = true;
170✔
113
            this.setAttribute('tabindex', '-1');
170✔
114
            this.removeAttribute('focusable');
170✔
115

170✔
116
            if (this.selfManageFocusElement) {
170✔
UNCOV
117
                return;
×
UNCOV
118
            }
×
119

170✔
120
            if (tabIndex !== -1) {
170✔
121
                this._tabIndex = tabIndex;
7✔
122
                this.manageFocusElementTabindex(tabIndex);
7✔
123
            } else {
170✔
124
                this.focusElement?.removeAttribute('tabindex');
163✔
125
            }
163✔
126
            return;
170✔
127
        }
170✔
128

110✔
129
        this.setAttribute('focusable', '');
110✔
130
        if (this.hasAttribute('tabindex')) {
485✔
131
            this.removeAttribute('tabindex');
34✔
132
        } else {
490✔
133
            // You can't remove an attribute that isn't there,
76✔
134
            // manually end the `manipulatingTabindex` guard.
76✔
135
            this.manipulatingTabindex = false;
76✔
136
        }
76✔
137

110✔
138
        this._tabIndex = tabIndex;
110✔
139
        this.manageFocusElementTabindex(tabIndex);
110✔
140
    }
18,942✔
141
    private _tabIndex = 0;
8,099✔
142

8,099✔
143
    private onPointerdownManagementOfTabIndex(): void {
8,099✔
144
        if (this.tabIndex === -1) {
2✔
145
            setTimeout(() => {
2✔
146
                // Ensure this happens _after_ WebKit attempts to focus the :host.
2✔
147
                this.tabIndex = 0;
2✔
148
                this.focus({ preventScroll: true });
2✔
149
                this.tabIndex = -1;
2✔
150
            });
2✔
151
        }
2✔
152
    }
2✔
153

8,099✔
154
    private async manageFocusElementTabindex(tabIndex: number): Promise<void> {
8,099✔
155
        if (!this.focusElement) {
117✔
156
            // allow setting these values to be async when needed.
44✔
157
            await this.updateComplete;
44✔
158
        }
44✔
159
        if (tabIndex === null) {
117✔
160
            this.focusElement.removeAttribute('tabindex');
2✔
161
        } else {
117✔
162
            if (this.focusElement !== this) {
115✔
163
                this.focusElement.tabIndex = tabIndex;
115✔
164
            }
115✔
165
        }
115✔
166
    }
117✔
167

8,099✔
168
    private manipulatingTabindex = false;
8,099✔
169

8,099✔
170
    /**
8,099✔
171
     * @private
8,099✔
172
     */
8,099✔
173
    public get focusElement(): DisableableElement {
8,099✔
174
        throw new Error('Must implement focusElement getter!');
1✔
175
    }
1✔
176

8,099✔
177
    /**
8,099✔
178
     * @public
8,099✔
179
     * @returns {boolean} whether the component should manage its focusElement tab-index or not
8,099✔
180
     * Needed for action-menu to be supported in action-group in an accessible way
8,099✔
181
     */
8,099✔
182
    public get selfManageFocusElement(): boolean {
8,099✔
183
        return false;
170✔
184
    }
170✔
185

8,099✔
186
    public override focus(options?: FocusOptions): void {
8,099✔
187
        if (this.disabled || !this.focusElement) {
730✔
188
            return;
11✔
189
        }
11✔
190

719✔
191
        if (this.focusElement !== this) {
720✔
192
            this.focusElement.focus(options);
233✔
193
        } else {
729✔
194
            HTMLElement.prototype.focus.apply(this, [options]);
486✔
195
        }
486✔
196
    }
730✔
197

8,099✔
198
    public override blur(): void {
8,099✔
199
        const focusElement = this.focusElement || this;
1,008✔
200
        if (focusElement !== this) {
1,008✔
201
            focusElement.blur();
37✔
202
        } else {
1,008✔
203
            HTMLElement.prototype.blur.apply(this);
971✔
204
        }
971✔
205
    }
1,008✔
206

8,099✔
207
    public override click(): void {
8,099✔
208
        if (this.disabled) {
335✔
209
            return;
7✔
210
        }
7✔
211

328✔
212
        const focusElement = this.focusElement || this;
333✔
213
        if (focusElement !== this) {
335✔
214
            focusElement.click();
6✔
215
        } else {
332✔
216
            HTMLElement.prototype.click.apply(this);
322✔
217
        }
322✔
218
    }
335✔
219

8,099✔
220
    protected manageAutoFocus(): void {
8,099✔
221
        if (this.autofocus) {
2✔
222
            /**
2✔
223
             * Trick :focus-visible polyfill into thinking keyboard based focus
2✔
224
             *
2✔
225
             * @private
2✔
226
             **/
2✔
227
            this.dispatchEvent(
2✔
228
                new KeyboardEvent('keydown', {
2✔
229
                    code: 'Tab',
2✔
230
                })
2✔
231
            );
2✔
232
            this.focusElement.focus();
2✔
233
        }
2✔
234
    }
2✔
235

8,099✔
236
    protected override firstUpdated(changes: PropertyValues): void {
8,099✔
237
        super.firstUpdated(changes);
8,097✔
238
        if (
8,097✔
239
            !this.hasAttribute('tabindex') ||
8,097✔
240
            this.getAttribute('tabindex') !== '-1'
1,100✔
241
        ) {
8,097✔
242
            this.setAttribute('focusable', '');
6,998✔
243
        }
6,998✔
244
    }
8,097✔
245

8,099✔
246
    protected override update(changedProperties: PropertyValues): void {
8,099✔
247
        if (changedProperties.has('disabled')) {
12,979✔
248
            this.handleDisabledChanged(
8,142✔
249
                this.disabled,
8,142✔
250
                changedProperties.get('disabled') as boolean
8,142✔
251
            );
8,142✔
252
        }
8,142✔
253

12,979✔
254
        super.update(changedProperties);
12,979✔
255
    }
12,979✔
256

8,099✔
257
    protected override updated(changedProperties: PropertyValues): void {
8,099✔
258
        super.updated(changedProperties);
18,443✔
259

18,443✔
260
        if (changedProperties.has('disabled') && this.disabled) {
18,443✔
261
            this.blur();
987✔
262
        }
987✔
263
    }
18,443✔
264

8,099✔
265
    private async handleDisabledChanged(
8,099✔
266
        disabled: boolean,
8,142✔
267
        oldDisabled: boolean
8,142✔
268
    ): Promise<void> {
8,142✔
269
        const canSetDisabled = (): boolean =>
8,142✔
270
            this.focusElement !== this &&
579✔
271
            typeof this.focusElement.disabled !== 'undefined';
39✔
272
        if (disabled) {
8,142✔
273
            this.manipulatingTabindex = true;
556✔
274
            this.setAttribute('tabindex', '-1');
556✔
275
            await this.updateComplete;
556✔
276
            if (canSetDisabled()) {
556✔
277
                this.focusElement.disabled = true;
24✔
278
            } else {
552✔
279
                this.setAttribute('aria-disabled', 'true');
532✔
280
            }
532✔
281
        } else if (oldDisabled) {
8,142✔
282
            this.manipulatingTabindex = true;
23✔
283
            if (this.focusElement === this) {
23✔
284
                this.setAttribute('tabindex', '' + this._tabIndex);
18✔
285
            } else {
23✔
286
                this.removeAttribute('tabindex');
5✔
287
            }
5✔
288
            await this.updateComplete;
23✔
289
            if (canSetDisabled()) {
23✔
290
                this.focusElement.disabled = false;
4✔
291
            } else {
23✔
292
                this.removeAttribute('aria-disabled');
19✔
293
            }
19✔
294
        }
23✔
295
    }
8,142✔
296

8,099✔
297
    protected override async getUpdateComplete(): Promise<boolean> {
8,099✔
298
        const complete = (await super.getUpdateComplete()) as boolean;
13,059✔
299
        await this.autofocusReady;
13,058✔
300
        return complete;
13,058✔
301
    }
13,059✔
302

8,099✔
303
    private autofocusReady = Promise.resolve();
8,099✔
304

95✔
305
    public override connectedCallback(): void {
95✔
306
        super.connectedCallback();
8,245✔
307
        if (this.autofocus) {
8,245✔
308
            this.autofocusReady = new Promise(async (res) => {
2✔
309
                // If at connect time the [autofocus] content is placed within
2✔
310
                // content that needs to be "hidden" by default, it would need to wait
2✔
311
                // two rAFs for animations to be triggered on that content in
2✔
312
                // order for the [autofocus] to become "visisble" and have its
2✔
313
                // focus() capabilities enabled.
2✔
314
                //
2✔
315
                // Await this with `getUpdateComplete` so that the element cannot
2✔
316
                // become "ready" until `manageFocus` has occured.
2✔
317
                await nextFrame();
2✔
318
                await nextFrame();
2✔
319
                res();
2✔
320
            });
2✔
321
            this.updateComplete.then(() => {
2✔
322
                this.manageAutoFocus();
2✔
323
            });
2✔
324
        }
2✔
325
    }
8,245✔
326
}
95✔
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

© 2026 Coveralls, Inc