• 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

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

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

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

54✔
25
function ensureMethod<T, RT>(
3,330✔
26
    value: T | RT | undefined,
3,330✔
27
    type: string,
3,330✔
28
    fallback: T
3,330✔
29
): T {
3,330✔
30
    if (typeof value === type) {
3,330✔
31
        return (() => value) as T;
817✔
32
    } else if (typeof value === 'function') {
3,330✔
33
        return value as T;
1,164✔
34
    }
1,164✔
35
    return fallback;
1,349✔
36
}
1,349✔
37

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

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

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

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

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

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

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

54✔
65
    public directionLength = 5;
54✔
66

54✔
67
    public hostDelegatesFocus = false;
54✔
68

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

54✔
73
    get elements(): T[] {
54✔
74
        if (!this.cachedElements) {
57,645✔
75
            this.cachedElements = this._elements();
2,467✔
76
        }
2,467✔
77
        return this.cachedElements;
57,645✔
78
    }
57,645✔
79

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

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

54✔
88
    protected get focused(): boolean {
54✔
89
        return this._focused;
14,264✔
90
    }
14,264✔
91

54✔
92
    private _focused = false;
54✔
93

54✔
94
    get focusInElement(): T {
54✔
95
        return this.elements[this.focusInIndex];
22,501✔
96
    }
22,501✔
97

54✔
98
    get focusInIndex(): number {
54✔
99
        return this._focusInIndex(this.elements);
22,945✔
100
    }
22,945✔
101

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

54✔
105
    host: ReactiveElement;
54✔
106

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

54✔
110
    isEventWithinListenerScope(event: Event): boolean {
54✔
111
        if (this._listenerScope() === this.host) return true;
416✔
112
        return event.composedPath().includes(this._listenerScope());
31✔
113
    }
416✔
114

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

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

54✔
121
    recentlyConnected = false;
54✔
122

54✔
123
    constructor(
54✔
124
        host: ReactiveElement,
1,110✔
125
        {
1,110✔
126
            hostDelegatesFocus,
1,110✔
127
            direction,
1,110✔
128
            elementEnterAction,
1,110✔
129
            elements,
1,110✔
130
            focusInIndex,
1,110✔
131
            isFocusableElement,
1,110✔
132
            listenerScope,
1,110✔
133
        }: FocusGroupConfig<T> = { elements: () => [] }
1,110✔
134
    ) {
1,110✔
135
        this.mutationObserver = new MutationObserver(() => {
1,110✔
136
            this.handleItemMutation();
1,628✔
137
        });
1,110✔
138
        this.hostDelegatesFocus = hostDelegatesFocus || false;
1,110✔
139
        this.host = host;
1,110✔
140
        this.host.addController(this);
1,110✔
141
        this._elements = elements;
1,110✔
142
        this.isFocusableElement = isFocusableElement || this.isFocusableElement;
1,110✔
143
        this._direction = ensureMethod<() => DirectionTypes, DirectionTypes>(
1,110✔
144
            direction,
1,110✔
145
            'string',
1,110✔
146
            this._direction
1,110✔
147
        );
1,110✔
148
        this.elementEnterAction = elementEnterAction || this.elementEnterAction;
1,110✔
149
        this._focusInIndex = ensureMethod<(_elements: T[]) => number, number>(
1,110✔
150
            focusInIndex,
1,110✔
151
            'number',
1,110✔
152
            this._focusInIndex
1,110✔
153
        );
1,110✔
154
        this._listenerScope = ensureMethod<() => HTMLElement, HTMLElement>(
1,110✔
155
            listenerScope,
1,110✔
156
            'object',
1,110✔
157
            this._listenerScope
1,110✔
158
        );
1,110✔
159
    }
1,110✔
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.
54✔
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;
54✔
162
    */
54✔
163
    handleItemMutation(): void {
54✔
164
        if (
1,628✔
165
            this._currentIndex == -1 ||
1,628✔
166
            this.elements.length <= this._elements().length
756✔
167
        )
1,628✔
168
            return;
1,628✔
169
        const focusedElement = this.elements[this.currentIndex];
16✔
170
        this.clearElementCache();
16✔
171
        if (this.elements.includes(focusedElement)) return;
16✔
172
        const moveToNextElement = this.currentIndex !== this.elements.length;
12✔
173
        const diff = moveToNextElement ? 1 : -1;
1,628✔
174
        if (moveToNextElement) {
1,628✔
175
            this.setCurrentIndexCircularly(-1);
3✔
176
        }
3✔
177
        this.setCurrentIndexCircularly(diff);
12✔
178
        this.focus();
12✔
179
    }
1,628✔
180

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

54✔
428
    hostConnected(): void {
54✔
429
        this.recentlyConnected = true;
1,102✔
430
        this.addEventListeners();
1,102✔
431
    }
1,102✔
432

54✔
433
    hostDisconnected(): void {
54✔
434
        this.mutationObserver.disconnect();
1,102✔
435
        this.removeEventListeners();
1,102✔
436
    }
1,102✔
437

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