• 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

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

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

13✔
14
type DirectionTypes = 'horizontal' | 'vertical' | 'both' | 'grid';
13✔
15
export type FocusGroupConfig<T> = {
13✔
16
    hostDelegatesFocus?: boolean;
13✔
17
    focusInIndex?: (_elements: T[]) => number;
13✔
18
    direction?: DirectionTypes | (() => DirectionTypes);
13✔
19
    elementEnterAction?: (el: T) => void;
13✔
20
    elements: () => T[];
13✔
21
    isFocusableElement?: (el: T) => boolean;
13✔
22
    listenerScope?: HTMLElement | (() => HTMLElement);
13✔
23
};
13✔
24

13✔
25
function ensureMethod<T, RT>(
252✔
26
    value: T | RT | undefined,
252✔
27
    type: string,
252✔
28
    fallback: T
252✔
29
): T {
252✔
30
    if (typeof value === type) {
252✔
31
        return (() => value) as T;
80✔
32
    } else if (typeof value === 'function') {
252✔
33
        return value as T;
89✔
34
    }
89✔
35
    return fallback;
83✔
36
}
83✔
37

13✔
38
export class FocusGroupController<T extends HTMLElement>
13✔
39
    implements ReactiveController
13✔
40
{
13✔
41
    protected cachedElements?: T[];
13✔
42
    private mutationObserver: MutationObserver;
13✔
43

13✔
44
    get currentIndex(): number {
13✔
45
        if (this._currentIndex === -1) {
13✔
46
            this._currentIndex = this.focusInIndex;
51✔
47
        }
51✔
48
        return this._currentIndex - this.offset;
13✔
49
    }
13✔
50

13✔
51
    set currentIndex(currentIndex) {
13✔
52
        this._currentIndex = currentIndex + this.offset;
139✔
53
    }
139✔
54

13✔
55
    private _currentIndex = -1;
13✔
56

13✔
57
    private prevIndex = -1;
13✔
58

13✔
59
    get direction(): DirectionTypes {
13✔
60
        return this._direction();
177✔
61
    }
177✔
62

13✔
63
    _direction = (): DirectionTypes => 'both';
13✔
64

13✔
65
    public directionLength = 5;
13✔
66

13✔
67
    public hostDelegatesFocus = false;
13✔
68

13✔
69
    elementEnterAction = (_el: T): void => {
13✔
70
        return;
42✔
71
    };
42✔
72

13✔
73
    get elements(): T[] {
13✔
74
        if (!this.cachedElements) {
4,523✔
75
            this.cachedElements = this._elements();
200✔
76
        }
200✔
77
        return this.cachedElements;
4,523✔
78
    }
4,523✔
79

13✔
80
    private _elements!: () => T[];
13✔
81

13✔
82
    protected set focused(focused: boolean) {
13✔
83
        /* c8 ignore next 1 */
13✔
84
        if (focused === this.focused) return;
13✔
85
        this._focused = focused;
50✔
86
    }
50✔
87

13✔
88
    protected get focused(): boolean {
13✔
89
        return this._focused;
1,029✔
90
    }
1,029✔
91

13✔
92
    private _focused = false;
13✔
93

13✔
94
    get focusInElement(): T {
13✔
95
        return this.elements[this.focusInIndex];
1,388✔
96
    }
1,388✔
97

13✔
98
    get focusInIndex(): number {
13✔
99
        return this._focusInIndex(this.elements);
1,448✔
100
    }
1,448✔
101

13✔
102
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
13✔
103
    _focusInIndex = (_elements: T[]): number => 0;
13✔
104

13✔
105
    host: ReactiveElement;
13✔
106

13✔
107
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
13✔
108
    isFocusableElement = (_el: T): boolean => true;
13✔
109

13✔
110
    isEventWithinListenerScope(event: Event): boolean {
13✔
111
        if (this._listenerScope() === this.host) return true;
68!
112
        return event.composedPath().includes(this._listenerScope());
×
113
    }
68✔
114

13✔
115
    _listenerScope = (): HTMLElement => this.host;
13✔
116

13✔
117
    // When elements are virtualized, the delta between the first element
13✔
118
    // and the first rendered element.
13✔
119
    offset = 0;
13✔
120

13✔
121
    recentlyConnected = false;
13✔
122

13✔
123
    constructor(
13✔
124
        host: ReactiveElement,
84✔
125
        {
84✔
126
            hostDelegatesFocus,
84✔
127
            direction,
84✔
128
            elementEnterAction,
84✔
129
            elements,
84✔
130
            focusInIndex,
84✔
131
            isFocusableElement,
84✔
132
            listenerScope,
84✔
133
        }: FocusGroupConfig<T> = { elements: () => [] }
84✔
134
    ) {
84✔
135
        this.mutationObserver = new MutationObserver(() => {
84✔
136
            this.handleItemMutation();
152✔
137
        });
84✔
138
        this.hostDelegatesFocus = hostDelegatesFocus || false;
84✔
139
        this.host = host;
84✔
140
        this.host.addController(this);
84✔
141
        this._elements = elements;
84✔
142
        this.isFocusableElement = isFocusableElement || this.isFocusableElement;
84✔
143
        this._direction = ensureMethod<() => DirectionTypes, DirectionTypes>(
84✔
144
            direction,
84✔
145
            'string',
84✔
146
            this._direction
84✔
147
        );
84✔
148
        this.elementEnterAction = elementEnterAction || this.elementEnterAction;
84✔
149
        this._focusInIndex = ensureMethod<(_elements: T[]) => number, number>(
84✔
150
            focusInIndex,
84✔
151
            'number',
84✔
152
            this._focusInIndex
84✔
153
        );
84✔
154
        this._listenerScope = ensureMethod<() => HTMLElement, HTMLElement>(
84✔
155
            listenerScope,
84✔
156
            'object',
84✔
157
            this._listenerScope
84✔
158
        );
84✔
159
    }
84✔
160
    /*  In  handleItemMutation() method the first if condition is checking if the element is not focused or if the element's children's length is not decreasing then it means no element has been deleted and we must return.
13✔
161
        Then we are checking if the deleted element was the focused one before the deletion if so then we need to proceed else we casn return;
13✔
162
    */
13✔
163
    handleItemMutation(): void {
13✔
164
        if (
152✔
165
            this._currentIndex == -1 ||
152✔
166
            this.elements.length <= this._elements().length
98✔
167
        )
152✔
168
            return;
152✔
169
        const focusedElement = this.elements[this.currentIndex];
7✔
170
        this.clearElementCache();
7✔
171
        if (this.elements.includes(focusedElement)) return;
7!
172
        const moveToNextElement = this.currentIndex !== this.elements.length;
7✔
173
        const diff = moveToNextElement ? 1 : -1;
152✔
174
        if (moveToNextElement) {
152✔
175
            this.setCurrentIndexCircularly(-1);
2✔
176
        }
2✔
177
        this.setCurrentIndexCircularly(diff);
7✔
178
        this.focus();
7✔
179
    }
152✔
180

13✔
181
    update({ elements }: FocusGroupConfig<T> = { elements: () => [] }): void {
13✔
182
        this.unmanage();
×
183
        this._elements = elements;
×
184
        this.clearElementCache();
×
185
        this.manage();
×
186
    }
×
187

13✔
188
    /**
13✔
189
     * resets the focusedItem to initial item
13✔
190
     */
13✔
191
    reset(): void {
13✔
192
        const elements = this.elements;
17✔
193
        if (!elements.length) return;
17✔
194
        this.setCurrentIndexCircularly(this.focusInIndex - this.currentIndex);
16✔
195
        let focusElement = elements[this.currentIndex];
16✔
196
        if (this.currentIndex < 0) {
17!
197
            return;
×
198
        }
✔
199
        if (!focusElement || !this.isFocusableElement(focusElement)) {
17!
200
            this.setCurrentIndexCircularly(1);
×
201
            focusElement = elements[this.currentIndex];
×
202
        }
✔
203
        if (focusElement && this.isFocusableElement(focusElement)) {
17✔
204
            elements[this.prevIndex]?.setAttribute('tabindex', '-1');
16!
205
            focusElement.setAttribute('tabindex', '0');
16✔
206
        }
16✔
207
    }
17✔
208

13✔
209
    focusOnItem(item?: T, options?: FocusOptions): void {
13✔
210
        if (
9✔
211
            item &&
9✔
212
            this.isFocusableElement(item) &&
9✔
213
            this.elements.indexOf(item)
9✔
214
        ) {
9✔
215
            const diff = this.elements.indexOf(item) - this.currentIndex;
4✔
216
            this.setCurrentIndexCircularly(diff);
4✔
217
            this.elements[this.prevIndex]?.setAttribute('tabindex', '-1');
4!
218
        }
4✔
219
        this.focus(options);
9✔
220
    }
9✔
221

13✔
222
    focus(options?: FocusOptions): void {
13✔
223
        const elements = this.elements;
74✔
224
        if (!elements.length) return;
74✔
225
        let focusElement = elements[this.currentIndex];
66✔
226
        if (!focusElement || !this.isFocusableElement(focusElement)) {
74✔
227
            this.setCurrentIndexCircularly(1);
1✔
228
            focusElement = elements[this.currentIndex];
1✔
229
        }
1✔
230
        if (focusElement && this.isFocusableElement(focusElement)) {
74✔
231
            if (
65✔
232
                !this.hostDelegatesFocus ||
65✔
233
                elements[this.prevIndex] !== focusElement
49✔
234
            ) {
65✔
235
                elements[this.prevIndex]?.setAttribute('tabindex', '-1');
62✔
236
            }
62✔
237
            focusElement.tabIndex = 0;
65✔
238
            focusElement.focus(options);
65✔
239
            if (this.hostDelegatesFocus && !this.focused) {
65✔
240
                this.hostContainsFocus();
1✔
241
            }
1✔
242
        }
65✔
243
    }
74✔
244

13✔
245
    clearElementCache(offset = 0): void {
13✔
246
        this.mutationObserver.disconnect();
241✔
247
        delete this.cachedElements;
241✔
248
        this.offset = offset;
241✔
249
        requestAnimationFrame(() => {
241✔
250
            this.elements.forEach((element) => {
235✔
251
                this.mutationObserver.observe(element, {
693✔
252
                    attributes: true,
693✔
253
                });
693✔
254
            });
235✔
255
        });
241✔
256
    }
241✔
257

13✔
258
    setCurrentIndexCircularly(diff: number): void {
13✔
259
        const { length } = this.elements;
71✔
260
        let steps = length;
71✔
261
        this.prevIndex = this.currentIndex;
71✔
262
        // start at a possibly not 0 index
71✔
263
        let nextIndex = (length + this.currentIndex + diff) % length;
71✔
264
        while (
71✔
265
            // don't cycle the elements more than once
71✔
266
            steps &&
71✔
267
            this.elements[nextIndex] &&
66✔
268
            !this.isFocusableElement(this.elements[nextIndex])
66✔
269
        ) {
71✔
270
            nextIndex = (length + nextIndex + diff) % length;
5✔
271
            steps -= 1;
5✔
272
        }
5✔
273
        this.currentIndex = nextIndex;
71✔
274
    }
71✔
275

13✔
276
    hostContainsFocus(): void {
13✔
277
        this.host.addEventListener('focusout', this.handleFocusout);
25✔
278
        this.host.addEventListener('keydown', this.handleKeydown);
25✔
279
        this.focused = true;
25✔
280
    }
25✔
281

13✔
282
    hostNoLongerContainsFocus(): void {
13✔
283
        this.host.addEventListener('focusin', this.handleFocusin);
25✔
284
        this.host.removeEventListener('focusout', this.handleFocusout);
25✔
285
        this.host.removeEventListener('keydown', this.handleKeydown);
25✔
286
        this.focused = false;
25✔
287
    }
25✔
288

13✔
289
    isRelatedTargetOrContainAnElement(event: FocusEvent): boolean {
13✔
290
        const relatedTarget = event.relatedTarget as null | Element;
137✔
291

137✔
292
        const isRelatedTargetAnElement = this.elements.includes(
137✔
293
            relatedTarget as T
137✔
294
        );
137✔
295
        const isRelatedTargetContainedWithinElements = this.elements.some(
137✔
296
            (el) => el.contains(relatedTarget)
137✔
297
        );
137✔
298
        return !(
137✔
299
            isRelatedTargetAnElement || isRelatedTargetContainedWithinElements
137✔
300
        );
137✔
301
    }
137✔
302

13✔
303
    handleFocusin = (event: FocusEvent): void => {
13✔
304
        if (!this.isEventWithinListenerScope(event)) return;
69!
305

69✔
306
        const path = event.composedPath() as T[];
69✔
307
        let targetIndex = -1;
69✔
308
        path.find((el) => {
69✔
309
            targetIndex = this.elements.indexOf(el);
106✔
310
            return targetIndex !== -1;
106✔
311
        });
69✔
312
        this.prevIndex = this.currentIndex;
69✔
313
        this.currentIndex = targetIndex > -1 ? targetIndex : this.currentIndex;
69✔
314

69✔
315
        if (this.isRelatedTargetOrContainAnElement(event)) {
69✔
316
            this.hostContainsFocus();
25✔
317
        }
25✔
318
    };
69✔
319

13✔
320
    /**
13✔
321
     * handleClick - Finds the element that was clicked and sets the tabindex to 0
13✔
322
     * @returns void
13✔
323
     */
13✔
324
    handleClick = (): void => {
13✔
325
        // Manually set the tabindex to 0 for the current element on receiving focus (from keyboard or mouse)
88✔
326
        const elements = this.elements;
88✔
327
        if (!elements.length) return;
88!
328
        let focusElement = elements[this.currentIndex];
88✔
329
        if (this.currentIndex < 0) {
88!
330
            return;
1✔
331
        }
1✔
332
        if (!focusElement || !this.isFocusableElement(focusElement)) {
88!
333
            this.setCurrentIndexCircularly(1);
1✔
334
            focusElement = elements[this.currentIndex];
1✔
335
        }
1✔
336
        if (focusElement && this.isFocusableElement(focusElement)) {
88✔
337
            elements[this.prevIndex]?.setAttribute('tabindex', '-1');
88✔
338
            focusElement.setAttribute('tabindex', '0');
88✔
339
        }
88✔
340
    };
88✔
341

13✔
342
    handleFocusout = (event: FocusEvent): void => {
13✔
343
        if (this.isRelatedTargetOrContainAnElement(event)) {
70✔
344
            this.hostNoLongerContainsFocus();
26✔
345
        }
26✔
346
    };
70✔
347

13✔
348
    acceptsEventKey(key: string): boolean {
13✔
349
        if (key === 'End' || key === 'Home') {
53!
350
            return true;
×
351
        }
×
352
        switch (this.direction) {
53✔
353
            case 'horizontal':
53!
354
                return key === 'ArrowLeft' || key === 'ArrowRight';
×
355
            case 'vertical':
53✔
356
                return key === 'ArrowUp' || key === 'ArrowDown';
53✔
357
            case 'both':
53!
358
            case 'grid':
53!
359
                return key.startsWith('Arrow');
×
360
        }
53✔
361
    }
53✔
362

13✔
363
    handleKeydown = (event: KeyboardEvent): void => {
13✔
364
        if (!this.acceptsEventKey(event.key) || event.defaultPrevented) {
54✔
365
            return;
13✔
366
        }
13✔
367
        let diff = 0;
42✔
368
        this.prevIndex = this.currentIndex;
42✔
369
        switch (event.key) {
42✔
370
            case 'ArrowRight':
54!
371
                diff += 1;
1✔
372
                break;
1✔
373
            case 'ArrowDown':
54✔
374
                diff += this.direction === 'grid' ? this.directionLength : 1;
33!
375
                break;
33✔
376
            case 'ArrowLeft':
54!
377
                diff -= 1;
1✔
378
                break;
1✔
379
            case 'ArrowUp':
54✔
380
                diff -= this.direction === 'grid' ? this.directionLength : 1;
10!
381
                break;
10✔
382
            case 'End':
54!
383
                this.currentIndex = 0;
1✔
384
                diff -= 1;
1✔
385
                break;
1✔
386
            case 'Home':
54!
387
                this.currentIndex = this.elements.length - 1;
1✔
388
                diff += 1;
1✔
389
                break;
1✔
390
        }
54✔
391
        event.preventDefault();
42✔
392
        if (this.direction === 'grid' && this.currentIndex + diff < 0) {
54!
393
            this.currentIndex = 0;
1✔
394
        } else if (
1✔
395
            this.direction === 'grid' &&
42!
396
            this.currentIndex + diff > this.elements.length - 1
1✔
397
        ) {
42!
398
            this.currentIndex = this.elements.length - 1;
1✔
399
        } else {
42✔
400
            this.setCurrentIndexCircularly(diff);
42✔
401
        }
42✔
402
        // To allow the `focusInIndex` to be calculated with the "after" state of the keyboard interaction
42✔
403
        // do `elementEnterAction` _before_ focusing the next element.
42✔
404
        this.elementEnterAction(this.elements[this.currentIndex]);
42✔
405
        this.focus();
42✔
406
    };
54✔
407

13✔
408
    manage(): void {
13✔
409
        this.addEventListeners();
11✔
410
    }
11✔
411

13✔
412
    unmanage(): void {
13✔
413
        this.removeEventListeners();
21✔
414
    }
21✔
415

13✔
416
    addEventListeners(): void {
13✔
417
        this.host.addEventListener('focusin', this.handleFocusin);
96✔
418
        this.host.addEventListener('click', this.handleClick);
96✔
419
    }
96✔
420

13✔
421
    removeEventListeners(): void {
13✔
422
        this.host.removeEventListener('focusin', this.handleFocusin);
106✔
423
        this.host.removeEventListener('focusout', this.handleFocusout);
106✔
424
        this.host.removeEventListener('keydown', this.handleKeydown);
106✔
425
        this.host.removeEventListener('click', this.handleClick);
106✔
426
    }
106✔
427

13✔
428
    hostConnected(): void {
13✔
429
        this.recentlyConnected = true;
85✔
430
        this.addEventListeners();
85✔
431
    }
85✔
432

13✔
433
    hostDisconnected(): void {
13✔
434
        this.mutationObserver.disconnect();
85✔
435
        this.removeEventListeners();
85✔
436
    }
85✔
437

13✔
438
    hostUpdated(): void {
13✔
439
        if (this.recentlyConnected) {
314✔
440
            this.recentlyConnected = false;
85✔
441
            this.elements.forEach((element) => {
85✔
442
                this.mutationObserver.observe(element, {
48✔
443
                    attributes: true,
48✔
444
                });
48✔
445
            });
85✔
446
        }
85✔
447
    }
314✔
448
}
13✔
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