-
Notifications
You must be signed in to change notification settings - Fork 774
/
FocusScope.tsx
352 lines (307 loc) · 13.2 KB
/
FocusScope.tsx
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
import * as React from 'react';
import { useComposedRefs } from '@radix-ui/react-compose-refs';
import { Primitive } from '@radix-ui/react-primitive';
import { useCallbackRef } from '@radix-ui/react-use-callback-ref';
const AUTOFOCUS_ON_MOUNT = 'focusScope.autoFocusOnMount';
const AUTOFOCUS_ON_UNMOUNT = 'focusScope.autoFocusOnUnmount';
const EVENT_OPTIONS = { bubbles: false, cancelable: true };
type FocusableTarget = HTMLElement | { focus(): void };
/* -------------------------------------------------------------------------------------------------
* FocusScope
* -----------------------------------------------------------------------------------------------*/
const FOCUS_SCOPE_NAME = 'FocusScope';
type FocusScopeElement = React.ElementRef<typeof Primitive.div>;
type PrimitiveDivProps = React.ComponentPropsWithoutRef<typeof Primitive.div>;
interface FocusScopeProps extends PrimitiveDivProps {
/**
* When `true`, tabbing from last item will focus first tabbable
* and shift+tab from first item will focus last tababble.
* @defaultValue false
*/
loop?: boolean;
/**
* When `true`, focus cannot escape the focus scope via keyboard,
* pointer, or a programmatic focus.
* @defaultValue false
*/
trapped?: boolean;
/**
* Event handler called when auto-focusing on mount.
* Can be prevented.
*/
onMountAutoFocus?: (event: Event) => void;
/**
* Event handler called when auto-focusing on unmount.
* Can be prevented.
*/
onUnmountAutoFocus?: (event: Event) => void;
}
const FocusScope = React.forwardRef<FocusScopeElement, FocusScopeProps>((props, forwardedRef) => {
const {
loop = false,
trapped = false,
onMountAutoFocus: onMountAutoFocusProp,
onUnmountAutoFocus: onUnmountAutoFocusProp,
...scopeProps
} = props;
const [container, setContainer] = React.useState<HTMLElement | null>(null);
const onMountAutoFocus = useCallbackRef(onMountAutoFocusProp);
const onUnmountAutoFocus = useCallbackRef(onUnmountAutoFocusProp);
const lastFocusedElementRef = React.useRef<HTMLElement | null>(null);
const composedRefs = useComposedRefs(forwardedRef, (node) => setContainer(node));
const focusScope = React.useRef({
paused: false,
pause() {
this.paused = true;
},
resume() {
this.paused = false;
},
}).current;
// Takes care of trapping focus if focus is moved outside programmatically for example
React.useEffect(() => {
if (trapped) {
function handleFocusIn(event: FocusEvent) {
if (focusScope.paused || !container) return;
const target = event.target as HTMLElement | null;
if (container.contains(target)) {
lastFocusedElementRef.current = target;
} else {
focus(lastFocusedElementRef.current, { select: true });
}
}
function handleFocusOut(event: FocusEvent) {
if (focusScope.paused || !container) return;
const relatedTarget = event.relatedTarget as HTMLElement | null;
// A `focusout` event with a `null` `relatedTarget` will happen in at least two cases:
//
// 1. When the user switches app/tabs/windows/the browser itself loses focus.
// 2. In Google Chrome, when the focused element is removed from the DOM.
//
// We let the browser do its thing here because:
//
// 1. The browser already keeps a memory of what's focused for when the page gets refocused.
// 2. In Google Chrome, if we try to focus the deleted focused element (as per below), it
// throws the CPU to 100%, so we avoid doing anything for this reason here too.
if (relatedTarget === null) return;
// If the focus has moved to an actual legitimate element (`relatedTarget !== null`)
// that is outside the container, we move focus to the last valid focused element inside.
if (!container.contains(relatedTarget)) {
focus(lastFocusedElementRef.current, { select: true });
}
}
// When the focused element gets removed from the DOM, browsers move focus
// back to the document.body. In this case, we move focus to the container
// to keep focus trapped correctly.
function handleMutations(mutations: MutationRecord[]) {
const focusedElement = document.activeElement as HTMLElement | null;
if (focusedElement !== document.body) return;
for (const mutation of mutations) {
if (mutation.removedNodes.length > 0) focus(container);
}
}
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('focusout', handleFocusOut);
const mutationObserver = new MutationObserver(handleMutations);
if (container) mutationObserver.observe(container, { childList: true, subtree: true });
return () => {
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('focusout', handleFocusOut);
mutationObserver.disconnect();
};
}
}, [trapped, container, focusScope.paused]);
React.useEffect(() => {
if (container) {
focusScopesStack.add(focusScope);
const previouslyFocusedElement = document.activeElement as HTMLElement | null;
const hasFocusedCandidate = container.contains(previouslyFocusedElement);
if (!hasFocusedCandidate) {
const mountEvent = new CustomEvent(AUTOFOCUS_ON_MOUNT, EVENT_OPTIONS);
container.addEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
container.dispatchEvent(mountEvent);
if (!mountEvent.defaultPrevented) {
focusFirst(removeLinks(getTabbableCandidates(container)), { select: true });
if (document.activeElement === previouslyFocusedElement) {
focus(container);
}
}
}
return () => {
container.removeEventListener(AUTOFOCUS_ON_MOUNT, onMountAutoFocus);
// We hit a react bug (fixed in v17) with focusing in unmount.
// We need to delay the focus a little to get around it for now.
// See: https://github.com/facebook/react/issues/17894
setTimeout(() => {
const unmountEvent = new CustomEvent(AUTOFOCUS_ON_UNMOUNT, EVENT_OPTIONS);
container.addEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
container.dispatchEvent(unmountEvent);
if (!unmountEvent.defaultPrevented) {
focus(previouslyFocusedElement ?? document.body, { select: true });
}
// we need to remove the listener after we `dispatchEvent`
container.removeEventListener(AUTOFOCUS_ON_UNMOUNT, onUnmountAutoFocus);
focusScopesStack.remove(focusScope);
}, 0);
};
}
}, [container, onMountAutoFocus, onUnmountAutoFocus, focusScope]);
// Takes care of looping focus (when tabbing whilst at the edges)
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
if (!loop && !trapped) return;
if (focusScope.paused) return;
const isTabKey = event.key === 'Tab' && !event.altKey && !event.ctrlKey && !event.metaKey;
const focusedElement = document.activeElement as HTMLElement | null;
if (isTabKey && focusedElement) {
const container = event.currentTarget as HTMLElement;
const [first, last] = getTabbableEdges(container);
const hasTabbableElementsInside = first && last;
// we can only wrap focus if we have tabbable edges
if (!hasTabbableElementsInside) {
if (focusedElement === container) event.preventDefault();
} else {
if (!event.shiftKey && focusedElement === last) {
event.preventDefault();
if (loop) focus(first, { select: true });
} else if (event.shiftKey && focusedElement === first) {
event.preventDefault();
if (loop) focus(last, { select: true });
}
}
}
},
[loop, trapped, focusScope.paused]
);
return (
<Primitive.div tabIndex={-1} {...scopeProps} ref={composedRefs} onKeyDown={handleKeyDown} />
);
});
FocusScope.displayName = FOCUS_SCOPE_NAME;
/* -------------------------------------------------------------------------------------------------
* Utils
* -----------------------------------------------------------------------------------------------*/
/**
* Attempts focusing the first element in a list of candidates.
* Stops when focus has actually moved.
*/
function focusFirst(candidates: HTMLElement[], { select = false } = {}) {
const previouslyFocusedElement = document.activeElement;
for (const candidate of candidates) {
focus(candidate, { select });
if (document.activeElement !== previouslyFocusedElement) return;
}
}
/**
* Returns the first and last tabbable elements inside a container.
*/
function getTabbableEdges(container: HTMLElement) {
const candidates = getTabbableCandidates(container);
const first = findVisible(candidates, container);
const last = findVisible(candidates.reverse(), container);
return [first, last] as const;
}
/**
* Returns a list of potential tabbable candidates.
*
* NOTE: This is only a close approximation. For example it doesn't take into account cases like when
* elements are not visible. This cannot be worked out easily by just reading a property, but rather
* necessitate runtime knowledge (computed styles, etc). We deal with these cases separately.
*
* See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
* Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1
*/
function getTabbableCandidates(container: HTMLElement) {
const nodes: HTMLElement[] = [];
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode: (node: any) => {
const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden';
if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP;
// `.tabIndex` is not the same as the `tabindex` attribute. It works on the
// runtime's understanding of tabbability, so this automatically accounts
// for any kind of element that could be tabbed to.
return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
},
});
while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement);
// we do not take into account the order of nodes with positive `tabIndex` as it
// hinders accessibility to have tab order different from visual order.
return nodes;
}
/**
* Returns the first visible element in a list.
* NOTE: Only checks visibility up to the `container`.
*/
function findVisible(elements: HTMLElement[], container: HTMLElement) {
for (const element of elements) {
// we stop checking if it's hidden at the `container` level (excluding)
if (!isHidden(element, { upTo: container })) return element;
}
}
function isHidden(node: HTMLElement, { upTo }: { upTo?: HTMLElement }) {
if (getComputedStyle(node).visibility === 'hidden') return true;
while (node) {
// we stop at `upTo` (excluding it)
if (upTo !== undefined && node === upTo) return false;
if (getComputedStyle(node).display === 'none') return true;
node = node.parentElement as HTMLElement;
}
return false;
}
function isSelectableInput(element: any): element is FocusableTarget & { select: () => void } {
return element instanceof HTMLInputElement && 'select' in element;
}
function focus(element?: FocusableTarget | null, { select = false } = {}) {
// only focus if that element is focusable
if (element && element.focus) {
const previouslyFocusedElement = document.activeElement;
// NOTE: we prevent scrolling on focus, to minimize jarring transitions for users
element.focus({ preventScroll: true });
// only select if its not the same element, it supports selection and we need to select
if (element !== previouslyFocusedElement && isSelectableInput(element) && select)
element.select();
}
}
/* -------------------------------------------------------------------------------------------------
* FocusScope stack
* -----------------------------------------------------------------------------------------------*/
type FocusScopeAPI = { paused: boolean; pause(): void; resume(): void };
const focusScopesStack = createFocusScopesStack();
function createFocusScopesStack() {
/** A stack of focus scopes, with the active one at the top */
let stack: FocusScopeAPI[] = [];
return {
add(focusScope: FocusScopeAPI) {
// pause the currently active focus scope (at the top of the stack)
const activeFocusScope = stack[0];
if (focusScope !== activeFocusScope) {
activeFocusScope?.pause();
}
// remove in case it already exists (because we'll re-add it at the top of the stack)
stack = arrayRemove(stack, focusScope);
stack.unshift(focusScope);
},
remove(focusScope: FocusScopeAPI) {
stack = arrayRemove(stack, focusScope);
stack[0]?.resume();
},
};
}
function arrayRemove<T>(array: T[], item: T) {
const updatedArray = [...array];
const index = updatedArray.indexOf(item);
if (index !== -1) {
updatedArray.splice(index, 1);
}
return updatedArray;
}
function removeLinks(items: HTMLElement[]) {
return items.filter((item) => item.tagName !== 'A');
}
const Root = FocusScope;
export {
FocusScope,
//
Root,
};
export type { FocusScopeProps };