-
Notifications
You must be signed in to change notification settings - Fork 12
/
Copy pathfocus-zone.ts
779 lines (695 loc) · 29 KB
/
focus-zone.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
import {polyfill as eventListenerSignalPolyfill} from './polyfills/event-listener-signal.js'
import {isMacOS} from './utils/user-agent.js'
import {IterateFocusableElements, iterateFocusableElements} from './utils/iterate-focusable-elements.js'
import {uniqueId} from './utils/unique-id.js'
eventListenerSignalPolyfill()
export type Direction = 'previous' | 'next' | 'start' | 'end'
export type FocusMovementKeys =
| 'ArrowLeft'
| 'ArrowDown'
| 'ArrowUp'
| 'ArrowRight'
| 'h'
| 'j'
| 'k'
| 'l'
| 'a'
| 's'
| 'w'
| 'd'
| 'Tab'
| 'Home'
| 'End'
| 'PageUp'
| 'PageDown'
| 'Backspace'
export enum FocusKeys {
// Left and right arrow keys (previous and next, respectively)
ArrowHorizontal = 0b0000000001,
// Up and down arrow keys (previous and next, respectively)
ArrowVertical = 0b0000000010,
// The "J" and "K" keys (next and previous, respectively)
JK = 0b0000000100,
// The "H" and "L" keys (previous and next, respectively)
HL = 0b0000001000,
// The Home and End keys (previous and next, respectively, to end)
HomeAndEnd = 0b0000010000,
// The PgUp and PgDn keys (previous and next, respectively, to end)
PageUpDown = 0b0100000000,
// The "W" and "S" keys (previous and next, respectively)
WS = 0b0000100000,
// The "A" and "D" keys (previous and next, respectively)
AD = 0b0001000000,
// The Tab key (next)
Tab = 0b0010000000,
// The Backspace key on Windows (not to be confused with the Delete key on macOS)
Backspace = 0b1000000000,
ArrowAll = FocusKeys.ArrowHorizontal | FocusKeys.ArrowVertical,
HJKL = FocusKeys.HL | FocusKeys.JK,
WASD = FocusKeys.WS | FocusKeys.AD,
All = FocusKeys.ArrowAll |
FocusKeys.HJKL |
FocusKeys.HomeAndEnd |
FocusKeys.PageUpDown |
FocusKeys.WASD |
FocusKeys.Tab,
}
const KEY_TO_BIT = {
ArrowLeft: FocusKeys.ArrowHorizontal,
ArrowDown: FocusKeys.ArrowVertical,
ArrowUp: FocusKeys.ArrowVertical,
ArrowRight: FocusKeys.ArrowHorizontal,
h: FocusKeys.HL,
j: FocusKeys.JK,
k: FocusKeys.JK,
l: FocusKeys.HL,
a: FocusKeys.AD,
s: FocusKeys.WS,
w: FocusKeys.WS,
d: FocusKeys.AD,
Tab: FocusKeys.Tab,
Home: FocusKeys.HomeAndEnd,
End: FocusKeys.HomeAndEnd,
PageUp: FocusKeys.PageUpDown,
PageDown: FocusKeys.PageUpDown,
Backspace: FocusKeys.Backspace,
} as {[k in FocusMovementKeys]: FocusKeys}
const KEY_TO_DIRECTION = {
ArrowLeft: 'previous',
ArrowDown: 'next',
ArrowUp: 'previous',
ArrowRight: 'next',
h: 'previous',
j: 'next',
k: 'previous',
l: 'next',
a: 'previous',
s: 'next',
w: 'previous',
d: 'next',
Tab: 'next',
Home: 'start',
End: 'end',
PageUp: 'start',
PageDown: 'end',
Backspace: 'previous',
} as {[k in FocusMovementKeys]: Direction}
/**
* Options that control the behavior of the arrow focus behavior.
*/
export type FocusZoneSettings = IterateFocusableElements & {
/**
* Choose the behavior applied in cases where focus is currently at either the first or
* last element of the container.
*
* "stop" - do nothing and keep focus where it was
* "wrap" - wrap focus around to the first element from the last, or the last element from the first
*
* Default: "stop"
*/
focusOutBehavior?: 'stop' | 'wrap'
/**
* If set, this will be called to get the next focusable element. If this function
* returns null, we will try to determine the next direction ourselves. Use the
* `bindKeys` option to customize which keys are listened to.
*
* The function can accept a Direction, indicating the direction focus should move,
* the HTMLElement that was previously focused, and lastly the `KeyboardEvent` object
* created by the original `"keydown"` event.
*/
getNextFocusable?: (direction: Direction, from: Element | undefined, event: KeyboardEvent) => HTMLElement | undefined
/**
* Called to decide if a focusable element is allowed to participate in the arrow
* key focus behavior.
*
* By default, all focusable elements within the given container will participate
* in the arrow key focus behavior. If you need to withhold some elements from
* participation, implement this callback to return false for those elements.
*/
focusableElementFilter?: (element: HTMLElement) => boolean
/**
* Bit flags that identify keys that will be bound to. Each available key either
* moves focus to the "next" element or the "previous" element, so it is best
* to only bind the keys that make sense to move focus in your UI. Use the `FocusKeys`
* object to discover supported keys.
*
* Use the bitwise "OR" operator (`|`) to combine key types. For example,
* `FocusKeys.WASD | FocusKeys.HJKL` represents all of W, A, S, D, H, J, K, and L.
*
* A note on FocusKeys.PageUpDown: This behavior does not support paging, so by default
* using these keys will result in the same behavior as Home and End. To override this
* behavior, implement `getNextFocusable`.
*
* The default for this setting is `FocusKeys.ArrowVertical | FocusKeys.HomeAndEnd`, unless
* `getNextFocusable` is provided, in which case `FocusKeys.ArrowAll | FocusKeys.HomeAndEnd`
* is used as the default.
*/
bindKeys?: FocusKeys
/**
* If provided, this signal can be used to disable the behavior and remove any
* event listeners.
*/
abortSignal?: AbortSignal
/**
* If `activeDescendantControl` is supplied, do not move focus or alter `tabindex` on
* any element. Instead, manage `aria-activedescendant` according to the ARIA best
* practices guidelines.
* @see https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_focus_activedescendant
*
* The given `activeDescendantControl` will be given an `aria-controls` attribute that
* references the ID of the `container`. Additionally, it will be given an
* `aria-activedescendant` attribute that references the ID of the currently-active
* descendant.
*
* This element will retain DOM focus as arrow keys are pressed.
*/
activeDescendantControl?: HTMLElement
/**
* Called each time the active descendant changes. Note that either of the parameters
* may be undefined, e.g. when an element in the container first becomes active, or
* when the controlling element becomes unfocused.
*/
onActiveDescendantChanged?: (
newActiveDescendant: HTMLElement | undefined,
previousActiveDescendant: HTMLElement | undefined,
directlyActivated: boolean,
) => void
/**
* This option allows customization of the behavior that determines which of the
* focusable elements should be focused when focus enters the container via the Tab key.
*
* When set to "first", whenever focus enters the container via Tab, we will focus the
* first focusable element. When set to "previous", the most recently focused element
* will be focused (fallback to first if there was no previous).
*
* The "closest" strategy works like "first", except either the first or the last element
* of the container will be focused, depending on the direction from which focus comes.
*
* If a function is provided, this function should return the HTMLElement intended
* to receive focus. This is useful if you want to focus the currently "selected"
* item or element.
*
* Default: "previous"
*
* For more information, @see https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_general_within
*/
focusInStrategy?: 'first' | 'closest' | 'previous' | ((previousFocusedElement: Element) => HTMLElement | undefined)
/**
* A boolean value indicating whether or not the browser should scroll the
* document to bring the newly-focused element into view. A value of `false`
* for `preventScroll` (the default) means that the browser will scroll the
* element into view after focusing it. If `preventScroll` is set to `true`,
* no scrolling will occur.
*/
preventScroll?: boolean
}
function getDirection(keyboardEvent: KeyboardEvent) {
const direction = KEY_TO_DIRECTION[keyboardEvent.key as keyof typeof KEY_TO_DIRECTION]
if (keyboardEvent.key === 'Tab' && keyboardEvent.shiftKey) {
return 'previous'
}
const isMac = isMacOS()
if ((isMac && keyboardEvent.metaKey) || (!isMac && keyboardEvent.ctrlKey)) {
if (keyboardEvent.key === 'ArrowLeft' || keyboardEvent.key === 'ArrowUp') {
return 'start'
} else if (keyboardEvent.key === 'ArrowRight' || keyboardEvent.key === 'ArrowDown') {
return 'end'
}
}
return direction
}
/**
* There are some situations where we do not want various keys to affect focus. This function
* checks for those situations.
* 1. Home and End should not move focus when a text input or textarea is active
* 2. Keys that would normally type characters into an input or navigate a select element should be ignored
* 3. The down arrow sometimes should not move focus when a select is active since that sometimes invokes the dropdown
* 4. Page Up and Page Down within a textarea should not have any effect
* 5. When in a text input or textarea, left should only move focus if the cursor is at the beginning of the input
* 6. When in a text input or textarea, right should only move focus if the cursor is at the end of the input
* 7. When in a textarea, up and down should only move focus if cursor is at the beginning or end, respectively.
* @param keyboardEvent
* @param activeElement
*/
function shouldIgnoreFocusHandling(keyboardEvent: KeyboardEvent, activeElement: Element | null) {
const key = keyboardEvent.key
// Get the number of characters in `key`, accounting for double-wide UTF-16 chars. If keyLength
// is 1, we can assume it's a "printable" character. Otherwise it's likely a control character.
// One exception is the Tab key, which is technically printable, but browsers generally assign
// its function to move focus rather than type a <TAB> character.
const keyLength = [...key].length
const isTextInput =
(activeElement instanceof HTMLInputElement && activeElement.type === 'text') ||
activeElement instanceof HTMLTextAreaElement
// If we would normally type a character into an input, ignore
// Also, Home and End keys should never affect focus when in a text input
if (isTextInput && (keyLength === 1 || key === 'Home' || key === 'End')) {
return true
}
// Some situations we want to ignore with <select> elements
if (activeElement instanceof HTMLSelectElement) {
// Regular typeable characters change the selection, so ignore those
if (keyLength === 1) {
return true
}
// On macOS, bare ArrowDown opens the select, so ignore that
if (key === 'ArrowDown' && isMacOS() && !keyboardEvent.metaKey) {
return true
}
// On other platforms, Alt+ArrowDown opens the select, so ignore that
if (key === 'ArrowDown' && !isMacOS() && keyboardEvent.altKey) {
return true
}
}
// Ignore page up and page down for textareas
if (activeElement instanceof HTMLTextAreaElement && (key === 'PageUp' || key === 'PageDown')) {
return true
}
if (isTextInput) {
const textInput = activeElement as HTMLInputElement | HTMLTextAreaElement
const cursorAtStart = textInput.selectionStart === 0 && textInput.selectionEnd === 0
const cursorAtEnd =
textInput.selectionStart === textInput.value.length && textInput.selectionEnd === textInput.value.length
// When in a text area or text input, only move focus left/right if at beginning/end of the field
if (key === 'ArrowLeft' && !cursorAtStart) {
return true
}
if (key === 'ArrowRight' && !cursorAtEnd) {
return true
}
// When in a text area, only move focus up/down if at beginning/end of the field
if (textInput instanceof HTMLTextAreaElement) {
if (key === 'ArrowUp' && !cursorAtStart) {
return true
}
if (key === 'ArrowDown' && !cursorAtEnd) {
return true
}
}
}
return false
}
export const isActiveDescendantAttribute = 'data-is-active-descendant'
/**
* A value of activated-directly for data-is-active-descendant indicates the descendant was activated
* by a manual user interaction with intent to move active descendant. This usually translates to the
* user pressing one of the bound keys (up/down arrow, etc) to move through the focus zone. This is
* intended to be roughly equivalent to the :focus-visible pseudo-class
**/
export const activeDescendantActivatedDirectly = 'activated-directly'
/**
* A value of activated-indirectly for data-is-active-descendant indicates the descendant was activated
* implicitly, and not by a direct key press. This includes focus zone being created from scratch, focusable
* elements being added/removed, and mouseover events. This is intended to be roughly equivalent
* to :focus:not(:focus-visible)
**/
export const activeDescendantActivatedIndirectly = 'activated-indirectly'
export const hasActiveDescendantAttribute = 'data-has-active-descendant'
/**
* Sets up the arrow key focus behavior for all focusable elements in the given `container`.
* @param container
* @param settings
* @returns
*/
export function focusZone(container: HTMLElement, settings?: FocusZoneSettings): AbortController {
const focusableElements: HTMLElement[] = []
const savedTabIndex = new WeakMap<HTMLElement, string | null>()
const bindKeys =
settings?.bindKeys ??
(settings?.getNextFocusable ? FocusKeys.ArrowAll : FocusKeys.ArrowVertical) | FocusKeys.HomeAndEnd
const focusOutBehavior = settings?.focusOutBehavior ?? 'stop'
const focusInStrategy = settings?.focusInStrategy ?? 'previous'
const activeDescendantControl = settings?.activeDescendantControl
const activeDescendantCallback = settings?.onActiveDescendantChanged
let currentFocusedElement: HTMLElement | undefined
const preventScroll = settings?.preventScroll ?? false
function getFirstFocusableElement() {
return focusableElements[0] as HTMLElement | undefined
}
function isActiveDescendantInputFocused() {
return document.activeElement === activeDescendantControl
}
function updateFocusedElement(to?: HTMLElement, directlyActivated = false) {
const from = currentFocusedElement
currentFocusedElement = to
if (activeDescendantControl) {
if (to && isActiveDescendantInputFocused()) {
setActiveDescendant(from, to, directlyActivated)
} else {
clearActiveDescendant()
}
return
}
if (from && from !== to && savedTabIndex.has(from)) {
from.setAttribute('tabindex', '-1')
}
to?.setAttribute('tabindex', '0')
}
function setActiveDescendant(from: HTMLElement | undefined, to: HTMLElement, directlyActivated = false) {
if (!to.id) {
to.setAttribute('id', uniqueId())
}
if (from && from !== to) {
from.removeAttribute(isActiveDescendantAttribute)
}
if (
!activeDescendantControl ||
(!directlyActivated && activeDescendantControl.getAttribute('aria-activedescendant') === to.id)
) {
// prevent active descendant callback from being called repeatedly if the same element is activated (e.g. via mousemove)
return
}
activeDescendantControl.setAttribute('aria-activedescendant', to.id)
container.setAttribute(hasActiveDescendantAttribute, to.id)
to.setAttribute(
isActiveDescendantAttribute,
directlyActivated ? activeDescendantActivatedDirectly : activeDescendantActivatedIndirectly,
)
activeDescendantCallback?.(to, from, directlyActivated)
}
function clearActiveDescendant(previouslyActiveElement = currentFocusedElement) {
if (focusInStrategy === 'first') {
currentFocusedElement = undefined
}
activeDescendantControl?.removeAttribute('aria-activedescendant')
container.removeAttribute(hasActiveDescendantAttribute)
previouslyActiveElement?.removeAttribute(isActiveDescendantAttribute)
activeDescendantCallback?.(undefined, previouslyActiveElement, false)
}
function beginFocusManagement(...elements: HTMLElement[]) {
const filteredElements = elements.filter(e => settings?.focusableElementFilter?.(e) ?? true)
if (filteredElements.length === 0) {
return
}
// Insert all elements atomically.
focusableElements.splice(findInsertionIndex(filteredElements), 0, ...filteredElements)
for (const element of filteredElements) {
// Set tabindex="-1" on all tabbable elements, but save the original
// value in case we need to disable the behavior
if (!savedTabIndex.has(element)) {
savedTabIndex.set(element, element.getAttribute('tabindex'))
}
element.setAttribute('tabindex', '-1')
}
if (!currentFocusedElement) {
updateFocusedElement(getFirstFocusableElement())
}
}
function findInsertionIndex(elementsToInsert: HTMLElement[]) {
// Assume that all passed elements are well-ordered.
const firstElementToInsert = elementsToInsert[0]
if (focusableElements.length === 0) return 0
// Because the focusable elements are in document order,
// we can do a binary search to find the insertion index.
let iMin = 0
let iMax = focusableElements.length - 1
while (iMin <= iMax) {
const i = Math.floor((iMin + iMax) / 2)
const element = focusableElements[i]
if (followsInDocument(firstElementToInsert, element)) {
iMax = i - 1
} else {
iMin = i + 1
}
}
return iMin
}
/**
* @returns true if the second argument follows the first argument in the document
*/
function followsInDocument(first: HTMLElement, second: HTMLElement) {
return (second.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_PRECEDING) > 0
}
function endFocusManagement(...elements: HTMLElement[]) {
for (const element of elements) {
const focusableElementIndex = focusableElements.indexOf(element)
if (focusableElementIndex >= 0) {
focusableElements.splice(focusableElementIndex, 1)
}
const savedIndex = savedTabIndex.get(element)
if (savedIndex !== undefined) {
if (savedIndex === null) {
element.removeAttribute('tabindex')
} else {
element.setAttribute('tabindex', savedIndex)
}
savedTabIndex.delete(element)
}
// If removing the last-focused element, move focus to the first element in the list.
if (element === currentFocusedElement) {
const nextElementToFocus = getFirstFocusableElement()
updateFocusedElement(nextElementToFocus)
}
}
}
const iterateFocusableElementsOptions: IterateFocusableElements = {
reverse: settings?.reverse,
strict: settings?.strict,
onlyTabbable: settings?.onlyTabbable,
}
// Take all tabbable elements within container under management
beginFocusManagement(...iterateFocusableElements(container, iterateFocusableElementsOptions))
// Open the first tabbable element for tabbing
const initialElement =
typeof focusInStrategy === 'function' ? focusInStrategy(document.body) : getFirstFocusableElement()
updateFocusedElement(initialElement)
// If the DOM structure of the container changes, make sure we keep our state up-to-date
// with respect to the focusable elements cache and its order
const observer = new MutationObserver(mutations => {
// Perform all removals first, in case element order has simply changed
for (const mutation of mutations) {
for (const removedNode of mutation.removedNodes) {
if (removedNode instanceof HTMLElement) {
endFocusManagement(...iterateFocusableElements(removedNode))
}
}
// If an element is hidden or disabled, remove it from the list of focusable elements
if (mutation.type === 'attributes' && mutation.oldValue === null) {
if (mutation.target instanceof HTMLElement) {
endFocusManagement(mutation.target)
}
}
}
for (const mutation of mutations) {
for (const addedNode of mutation.addedNodes) {
if (addedNode instanceof HTMLElement) {
beginFocusManagement(...iterateFocusableElements(addedNode, iterateFocusableElementsOptions))
}
}
// Similarly, if an element is unhidden or "enabled", add it to the list of focusable elements
// If `mutation.oldValue` is not null, then we may assume that the element was previously hidden or disabled
if (mutation.type === 'attributes' && mutation.oldValue !== null) {
if (mutation.target instanceof HTMLElement) {
beginFocusManagement(mutation.target)
}
}
}
})
observer.observe(container, {
subtree: true,
childList: true,
attributeFilter: ['hidden', 'disabled'],
attributeOldValue: true,
})
const controller = new AbortController()
const signal = settings?.abortSignal ?? controller.signal
signal.addEventListener('abort', () => {
// Clean up any modifications
endFocusManagement(...focusableElements)
})
let elementIndexFocusedByClick: number | undefined = undefined
container.addEventListener(
'mousedown',
event => {
// Since focusin is only called when focus changes, we need to make sure the clicked
// element isn't already focused.
if (event.target instanceof HTMLElement && event.target !== document.activeElement) {
elementIndexFocusedByClick = focusableElements.indexOf(event.target)
}
},
{signal},
)
if (activeDescendantControl) {
container.addEventListener('focusin', event => {
if (event.target instanceof HTMLElement && focusableElements.includes(event.target)) {
// Move focus to the activeDescendantControl if one of the descendants is focused
activeDescendantControl.focus({preventScroll})
updateFocusedElement(event.target)
}
})
container.addEventListener(
'mousemove',
({target}) => {
if (!(target instanceof Node)) {
return
}
const focusableElement = focusableElements.find(element => element.contains(target))
if (focusableElement) {
updateFocusedElement(focusableElement)
}
},
{signal, capture: true},
)
// Listeners specifically on the controlling element
activeDescendantControl.addEventListener('focusin', () => {
// Focus moved into the active descendant input. Activate current or first descendant.
if (!currentFocusedElement) {
updateFocusedElement(getFirstFocusableElement())
} else {
setActiveDescendant(undefined, currentFocusedElement)
}
})
activeDescendantControl.addEventListener('focusout', () => {
clearActiveDescendant()
})
} else {
// This is called whenever focus enters an element in the container
container.addEventListener(
'focusin',
event => {
if (event.target instanceof HTMLElement) {
// If a click initiated the focus movement, we always want to set our internal state
// to reflect the clicked element as the currently focused one.
if (elementIndexFocusedByClick !== undefined) {
if (elementIndexFocusedByClick >= 0) {
if (focusableElements[elementIndexFocusedByClick] !== currentFocusedElement) {
updateFocusedElement(focusableElements[elementIndexFocusedByClick])
}
}
elementIndexFocusedByClick = undefined
} else {
// Set tab indexes and internal state based on the focus handling strategy
if (focusInStrategy === 'previous') {
updateFocusedElement(event.target)
} else if (focusInStrategy === 'closest' || focusInStrategy === 'first') {
if (event.relatedTarget instanceof Element && !container.contains(event.relatedTarget)) {
// Regardless of the previously focused element, if we're coming from outside the
// container, put focus onto the first encountered element (from above, it's The
// first element of the container; from below, it's the last). If the
// focusInStrategy is set to "first", lastKeyboardFocusDirection will always
// be undefined.
const targetElementIndex = lastKeyboardFocusDirection === 'previous' ? focusableElements.length - 1 : 0
const targetElement = focusableElements[targetElementIndex] as HTMLElement | undefined
targetElement?.focus({preventScroll})
return
} else {
updateFocusedElement(event.target)
}
} else if (typeof focusInStrategy === 'function') {
if (event.relatedTarget instanceof Element && !container.contains(event.relatedTarget)) {
const elementToFocus = focusInStrategy(event.relatedTarget)
const requestedFocusElementIndex = elementToFocus ? focusableElements.indexOf(elementToFocus) : -1
if (requestedFocusElementIndex >= 0 && elementToFocus instanceof HTMLElement) {
// Since we are calling focus() this handler will run again synchronously. Therefore,
// we don't want to let this invocation finish since it will clobber the value of
// currentFocusedElement.
elementToFocus.focus({preventScroll})
return
} else {
// eslint-disable-next-line no-console
console.warn('Element requested is not a known focusable element.')
}
} else {
updateFocusedElement(event.target)
}
}
}
}
lastKeyboardFocusDirection = undefined
},
{signal},
)
}
const keyboardEventRecipient = activeDescendantControl ?? container
// If the strategy is "closest", we need to capture the direction that the user
// is trying to move focus before our focusin handler is executed.
let lastKeyboardFocusDirection: Direction | undefined = undefined
if (focusInStrategy === 'closest') {
document.addEventListener(
'keydown',
event => {
if (event.key === 'Tab') {
lastKeyboardFocusDirection = getDirection(event)
}
},
{signal, capture: true},
)
}
function getCurrentFocusedIndex() {
if (!currentFocusedElement) {
return 0
}
const focusedIndex = focusableElements.indexOf(currentFocusedElement)
const fallbackIndex = currentFocusedElement === container ? -1 : 0
return focusedIndex !== -1 ? focusedIndex : fallbackIndex
}
// "keydown" is the event that triggers DOM focus change, so that is what we use here
keyboardEventRecipient.addEventListener(
'keydown',
event => {
if (event.key in KEY_TO_DIRECTION) {
const keyBit = KEY_TO_BIT[event.key as keyof typeof KEY_TO_BIT]
// Check if the pressed key (keyBit) is one that is being used for focus (bindKeys)
if (
!event.defaultPrevented &&
(keyBit & bindKeys) > 0 &&
!shouldIgnoreFocusHandling(event, document.activeElement)
) {
// Moving forward or backward?
const direction = getDirection(event)
let nextElementToFocus: HTMLElement | undefined = undefined
// If there is a custom function that retrieves the next focusable element, try calling that first.
if (settings?.getNextFocusable) {
nextElementToFocus = settings.getNextFocusable(direction, document.activeElement ?? undefined, event)
}
if (!nextElementToFocus) {
const lastFocusedIndex = getCurrentFocusedIndex()
let nextFocusedIndex = lastFocusedIndex
if (direction === 'previous') {
nextFocusedIndex -= 1
} else if (direction === 'start') {
nextFocusedIndex = 0
} else if (direction === 'next') {
nextFocusedIndex += 1
} else {
// end
nextFocusedIndex = focusableElements.length - 1
}
if (nextFocusedIndex < 0) {
// Tab should never cause focus to wrap. Use focusTrap for that behavior.
if (focusOutBehavior === 'wrap' && event.key !== 'Tab') {
nextFocusedIndex = focusableElements.length - 1
} else {
nextFocusedIndex = 0
}
}
if (nextFocusedIndex >= focusableElements.length) {
if (focusOutBehavior === 'wrap' && event.key !== 'Tab') {
nextFocusedIndex = 0
} else {
nextFocusedIndex = focusableElements.length - 1
}
}
if (lastFocusedIndex !== nextFocusedIndex) {
nextElementToFocus = focusableElements[nextFocusedIndex]
}
}
if (activeDescendantControl) {
updateFocusedElement(nextElementToFocus || currentFocusedElement, true)
} else if (nextElementToFocus) {
lastKeyboardFocusDirection = direction
// updateFocusedElement will be called implicitly when focus moves, as long as the event isn't prevented somehow
nextElementToFocus.focus({preventScroll})
}
// Tab should always allow escaping from this container, so only
// preventDefault if tab key press already resulted in a focus movement
if (event.key !== 'Tab' || nextElementToFocus) {
event.preventDefault()
}
}
}
},
{signal},
)
return controller
}