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

adobe / spectrum-web-components / 14094674923

26 Mar 2025 10:27PM UTC coverage: 86.218% (-11.8%) from 98.002%
14094674923

Pull #5221

github

web-flow
Merge 2a1ea92e7 into 3184c1e6a
Pull Request #5221: RFC | leverage css module imports in components

1737 of 2032 branches covered (85.48%)

Branch coverage included in aggregate %.

14184 of 16434 relevant lines covered (86.31%)

85.29 hits per line

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

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

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

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

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

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

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

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

422✔
43
    /**
422✔
44
     * The tab index to apply to this control. See general documentation about
422✔
45
     * the tabindex HTML property
422✔
46
     *
422✔
47
     * @private
422✔
48
     */
422✔
49
    @property({ type: Number })
422✔
50
    public override get tabIndex(): number {
422✔
51
        if (this.focusElement === this) {
422✔
52
            const tabindex = this.hasAttribute('tabindex')
350✔
53
                ? Number(this.getAttribute('tabindex'))
2,109✔
54
                : NaN;
631✔
55
            return !isNaN(tabindex) ? tabindex : -1;
350✔
56
        }
350✔
57
        const tabIndexAttribute = parseFloat(
969✔
58
            this.hasAttribute('tabindex')
969✔
59
                ? (this.getAttribute('tabindex') as string) || '0'
687!
60
                : '0'
392✔
61
        );
422✔
62
        // When `disabled` tabindex is -1.
422✔
63
        // When host tabindex -1, use that as the cache.
422✔
64
        if (this.disabled || tabIndexAttribute < 0) {
422✔
65
            return -1;
685✔
66
        }
685✔
67
        // When `focusElement` isn't available yet,
394✔
68
        // use host tabindex as the cache.
394✔
69
        if (!this.focusElement) {
113✔
70
            return tabIndexAttribute;
205✔
71
        }
205✔
72
        // All other times, use the tabindex of `focusElement`
189✔
73
        // as the cache for this value.
189✔
74
        // return this.focusElement.tabIndex;
189✔
75
        return this._tabIndex;
189✔
76
    }
422✔
77
    public override set tabIndex(tabIndex: number) {
422✔
78
        // Flipping `manipulatingTabindex` to true before a change
1,513✔
79
        // allows for that change NOT to effect the cached value of tabindex
1,513✔
80
        if (this.manipulatingTabindex) {
1,513✔
81
            this.manipulatingTabindex = false;
582✔
82
            return;
582✔
83
        }
582✔
84

931✔
85
        if (this.focusElement === this) {
1,131✔
86
            if (this.disabled) {
664✔
87
                this._tabIndex = tabIndex;
19✔
88
            } else if (tabIndex !== this._tabIndex) {
664✔
89
                this._tabIndex = tabIndex;
374✔
90
                const tabindex = '' + tabIndex;
374✔
91
                this.manipulatingTabindex = true;
374✔
92
                this.setAttribute('tabindex', tabindex);
374✔
93
            }
374✔
94
            return;
664✔
95
        }
664✔
96

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

267✔
111
        if (tabIndex === -1 || this.disabled) {
1,513✔
112
            this.manipulatingTabindex = true;
166✔
113
            this.setAttribute('tabindex', '-1');
166✔
114
            this.removeAttribute('focusable');
166✔
115

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

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

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

101✔
138
        this._tabIndex = tabIndex;
101✔
139
        this.manageFocusElementTabindex(tabIndex);
101✔
140
    }
1,513✔
141
    private _tabIndex = 0;
422✔
142

422✔
143
    private onPointerdownManagementOfTabIndex(): void {
422✔
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

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

422✔
168
    private manipulatingTabindex = false;
422✔
169

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

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

422✔
186
    public override focus(options?: FocusOptions): void {
422✔
187
        if (this.disabled || !this.focusElement) {
92✔
188
            return;
1✔
189
        }
1✔
190

91✔
191
        if (this.focusElement !== this) {
92✔
192
            this.focusElement.focus(options);
18✔
193
        } else {
92✔
194
            HTMLElement.prototype.focus.apply(this, [options]);
73✔
195
        }
73✔
196
    }
92✔
197

422✔
198
    public override blur(): void {
422✔
199
        const focusElement = this.focusElement || this;
42✔
200
        if (focusElement !== this) {
42✔
201
            focusElement.blur();
11✔
202
        } else {
42✔
203
            HTMLElement.prototype.blur.apply(this);
31✔
204
        }
31✔
205
    }
42✔
206

422✔
207
    public override click(): void {
422✔
208
        if (this.disabled) {
96✔
209
            return;
4✔
210
        }
4✔
211

92✔
212
        const focusElement = this.focusElement || this;
95✔
213
        if (focusElement !== this) {
96✔
214
            focusElement.click();
2✔
215
        } else {
95✔
216
            HTMLElement.prototype.click.apply(this);
90✔
217
        }
90✔
218
    }
96✔
219

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

422✔
236
    protected override firstUpdated(changes: PropertyValues): void {
422✔
237
        super.firstUpdated(changes);
421✔
238
        if (
421✔
239
            !this.hasAttribute('tabindex') ||
421✔
240
            this.getAttribute('tabindex') !== '-1'
39✔
241
        ) {
421✔
242
            this.setAttribute('focusable', '');
383✔
243
        }
383✔
244
    }
421✔
245

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

778✔
254
        super.update(changedProperties);
778✔
255
    }
778✔
256

422✔
257
    protected override updated(changedProperties: PropertyValues): void {
422✔
258
        super.updated(changedProperties);
1,215✔
259

1,215✔
260
        if (changedProperties.has('disabled') && this.disabled) {
1,215✔
261
            this.blur();
34✔
262
        }
34✔
263
    }
1,215✔
264

422✔
265
    private async handleDisabledChanged(
422✔
266
        disabled: boolean,
435✔
267
        oldDisabled: boolean
435✔
268
    ): Promise<void> {
435✔
269
        const canSetDisabled = (): boolean =>
435✔
270
            this.focusElement !== this &&
34✔
271
            typeof this.focusElement.disabled !== 'undefined';
10✔
272
        if (disabled) {
435✔
273
            this.manipulatingTabindex = true;
26✔
274
            this.setAttribute('tabindex', '-1');
26✔
275
            await this.updateComplete;
26✔
276
            if (canSetDisabled()) {
26✔
277
                this.focusElement.disabled = true;
×
278
            } else {
26✔
279
                this.setAttribute('aria-disabled', 'true');
26✔
280
            }
26✔
281
        } else if (oldDisabled) {
435✔
282
            this.manipulatingTabindex = true;
8✔
283
            if (this.focusElement === this) {
8✔
284
                this.setAttribute('tabindex', '' + this._tabIndex);
7✔
285
            } else {
8✔
286
                this.removeAttribute('tabindex');
1✔
287
            }
1✔
288
            await this.updateComplete;
8✔
289
            if (canSetDisabled()) {
8✔
290
                this.focusElement.disabled = false;
×
291
            } else {
8✔
292
                this.removeAttribute('aria-disabled');
8✔
293
            }
8✔
294
        }
8✔
295
    }
435✔
296

422✔
297
    protected override async getUpdateComplete(): Promise<boolean> {
422✔
298
        const complete = (await super.getUpdateComplete()) as boolean;
1,091✔
299
        await this.autofocusReady;
1,090✔
300
        return complete;
1,090✔
301
    }
1,091✔
302

422✔
303
    private autofocusReady = Promise.resolve();
422✔
304

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