Skip to content

Commit 04221df

Browse files
committed
Simplify focus restoration when containment is disabled
1 parent d150ad6 commit 04221df

File tree

1 file changed

+61
-91
lines changed

1 file changed

+61
-91
lines changed

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 61 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,8 @@ export function FocusScope(props: FocusScopeProps) {
120120
}, [parentScope]);
121121

122122
useAutoFocus(scopeRef.current, autoFocus);
123-
useFocusContainment(scopeRef.current, contain);
124-
useRestoreFocus(scopeRef.current, restoreFocus, contain);
123+
let nodeToRestoreRef = useRestoreFocus(scopeRef.current, restoreFocus);
124+
useTabOrderSplice(scopeRef.current, contain, nodeToRestoreRef);
125125

126126
let focusManager = createFocusManagerForScope(scopeRef.current);
127127

@@ -225,18 +225,18 @@ function getScopeRoot(scope: Scope) {
225225
return scope[0].parentElement;
226226
}
227227

228-
function useFocusContainment(scope: Scope, contain: boolean) {
228+
function useTabOrderSplice(scope: Scope, contain: boolean, domRef: React.RefObject<HTMLElement> | undefined) {
229229
let focusedNode = useRef<HTMLElement>();
230230

231231
let raf = useRef(null);
232232
useLayoutEffect(() => {
233-
if (!contain) {
234-
return;
235-
}
236-
237233
// Handle the Tab key to contain focus within the scope
238-
let onKeyDown = (e) => {
239-
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey || scope !== containedScope) {
234+
let onKeyDown = (e: KeyboardEvent) => {
235+
if (e.key !== 'Tab' || e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) {
236+
return;
237+
}
238+
239+
if (contain && scope !== containedScope) {
240240
return;
241241
}
242242

@@ -245,29 +245,51 @@ function useFocusContainment(scope: Scope, contain: boolean) {
245245
return;
246246
}
247247

248-
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable: true}, scope);
248+
// Create a DOM tree walker that matches all tabbable elements (and when contained, filtered to the current scope)
249+
let walker = getFocusableTreeWalker(getScopeRoot(scope), {tabbable: true}, contain ? scope : undefined);
250+
251+
// Find the next tabbable element after the currently focused element
249252
walker.currentNode = focusedElement;
250253
let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
251-
if (!nextElement) {
252-
walker.currentNode = e.shiftKey ? scope[scope.length - 1].nextElementSibling : scope[0].previousElementSibling;
253-
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
254+
255+
if (contain) {
256+
if (!nextElement) {
257+
// wrap focus to the opposite end of the scope
258+
walker.currentNode = e.shiftKey ? scope[scope.length - 1].nextElementSibling : scope[0].previousElementSibling;
259+
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
260+
}
261+
} else if (domRef?.current) {
262+
walker.currentNode = domRef.current;
263+
264+
// Skip over elements within the scope, in case the scope immediately follows the domRef.
265+
do {
266+
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
267+
} while (isElementInScope(nextElement, scope));
268+
269+
// If there is no next element and the domRef isn't within a FocusScope (i.e. we are leaving the top level focus scope)
270+
// then move focus to the body.
271+
// Otherwise restore focus to the domRef (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger)
272+
if (!nextElement && !isElementInAnyScope(domRef.current)) {
273+
focusedElement.blur();
274+
}
254275
}
255276

256-
e.preventDefault();
257277
if (nextElement) {
278+
// prevent native focus movement
279+
e.preventDefault();
258280
focusElement(nextElement, true);
259281
}
260282
};
261283

262-
let onFocus = (e) => {
284+
let onFocus = (e: FocusEvent) => {
263285
// If focusing an element in a child scope of the currently active scope, the child becomes active.
264286
// Moving out of the active scope to an ancestor is not allowed.
265-
if (isElementInScope(e.target, scope)) {
287+
if (isElementInScope(e.target as HTMLElement, scope)) {
266288
if (!containedScope || isAncestorScope(containedScope, scope)) {
267289
containedScope = scope;
268-
focusedNode.current = e.target;
290+
focusedNode.current = e.target as HTMLElement;
269291
}
270-
} else if (scope === containedScope && !isElementInChildScope(e.target, scope)) {
292+
} else if (scope === containedScope && !isElementInChildScope(e.target as HTMLElement, scope)) {
271293
// If a focus event occurs outside the active scope (e.g. user tabs from browser location bar),
272294
// restore focus to the previously focused node or the first tabbable element in the active scope.
273295
if (focusedNode.current) {
@@ -276,31 +298,35 @@ function useFocusContainment(scope: Scope, contain: boolean) {
276298
focusFirstInScope(containedScope);
277299
}
278300
} else if (scope === containedScope) {
279-
focusedNode.current = e.target;
301+
focusedNode.current = e.target as HTMLElement;
280302
}
281303
};
282304

283-
let onBlur = (e) => {
305+
let onBlur = (e: FocusEvent) => {
284306
// Firefox doesn't shift focus back to the Dialog properly without this
285307
raf.current = requestAnimationFrame(() => {
286308
// Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
287309
if (scope === containedScope && !isElementInChildScope(document.activeElement, scope)) {
288310
containedScope = scope;
289-
focusedNode.current = e.target;
311+
focusedNode.current = e.target as HTMLElement;
290312
focusedNode.current.focus();
291313
}
292314
});
293315
};
294316

295317
document.addEventListener('keydown', onKeyDown, false);
296-
document.addEventListener('focusin', onFocus, false);
297-
document.addEventListener('focusout', onBlur, false);
318+
if (contain) {
319+
document.addEventListener('focusin', onFocus, false);
320+
document.addEventListener('focusout', onBlur, false);
321+
}
298322
return () => {
299323
document.removeEventListener('keydown', onKeyDown, false);
300-
document.removeEventListener('focusin', onFocus, false);
301-
document.removeEventListener('focusout', onBlur, false);
324+
if (contain) {
325+
document.removeEventListener('focusin', onFocus, false);
326+
document.removeEventListener('focusout', onBlur, false);
327+
}
302328
};
303-
}, [scope, contain]);
329+
}, [scope, contain, domRef]);
304330

305331
// eslint-disable-next-line arrow-body-style
306332
useEffect(() => {
@@ -379,84 +405,28 @@ function useAutoFocus(scope: Scope, autoFocus: boolean) {
379405
}, [scope]);
380406
}
381407

382-
function useRestoreFocus(scope: Scope, restoreFocus: boolean, contain: boolean) {
408+
function useRestoreFocus(scope: Scope, restoreFocus: boolean) {
383409
// create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts.
384-
const nodeToRestore = useRef(typeof document !== 'undefined' ? document.activeElement as HTMLElement : null);
410+
const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? document.activeElement as HTMLElement : null);
385411
useLayoutEffect(() => {
412+
let nodeToRestore = nodeToRestoreRef.current;
413+
386414
if (!restoreFocus) {
387415
return;
388416
}
389417

390-
// Handle the Tab key so that tabbing out of the scope goes to the next element
391-
// after the node that had focus when the scope mounted. This is important when
392-
// using portals for overlays, so that focus goes to the expected element when
393-
// tabbing out of the overlay.
394-
let onKeyDown = (e: KeyboardEvent) => {
395-
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) {
396-
return;
397-
}
398-
399-
let focusedElement = document.activeElement as HTMLElement;
400-
if (!isElementInScope(focusedElement, scope)) {
401-
return;
402-
}
403-
404-
// Create a DOM tree walker that matches all tabbable elements
405-
let walker = getFocusableTreeWalker(document.body, {tabbable: true});
406-
407-
// Find the next tabbable element after the currently focused element
408-
walker.currentNode = focusedElement;
409-
let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
410-
411-
if (!document.body.contains(nodeToRestore.current) || nodeToRestore.current === document.body) {
412-
nodeToRestore.current = null;
413-
}
414-
415-
// If there is no next element, or it is outside the current scope, move focus to the
416-
// next element after the node to restore to instead.
417-
if ((!nextElement || !isElementInScope(nextElement, scope)) && nodeToRestore.current) {
418-
walker.currentNode = nodeToRestore.current;
419-
420-
// Skip over elements within the scope, in case the scope immediately follows the node to restore.
421-
do {
422-
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
423-
} while (isElementInScope(nextElement, scope));
424-
425-
e.preventDefault();
426-
e.stopPropagation();
427-
if (nextElement) {
428-
focusElement(nextElement, true);
429-
} else {
430-
// If there is no next element and the nodeToRestore isn't within a FocusScope (i.e. we are leaving the top level focus scope)
431-
// then move focus to the body.
432-
// Otherwise restore focus to the nodeToRestore (e.g menu within a popover -> tabbing to close the menu should move focus to menu trigger)
433-
if (!isElementInAnyScope(nodeToRestore.current)) {
434-
focusedElement.blur();
435-
} else {
436-
focusElement(nodeToRestore.current, true);
437-
}
438-
}
439-
}
440-
};
441-
442-
if (!contain) {
443-
document.addEventListener('keydown', onKeyDown, true);
444-
}
445-
446418
return () => {
447-
if (!contain) {
448-
document.removeEventListener('keydown', onKeyDown, true);
449-
}
450-
451419
if (restoreFocus && nodeToRestore && isElementInScope(document.activeElement, scope)) {
452420
requestAnimationFrame(() => {
453-
if (document.body.contains(nodeToRestore.current)) {
454-
focusElement(nodeToRestore.current);
421+
if (document.body.contains(nodeToRestore)) {
422+
focusElement(nodeToRestore);
455423
}
456424
});
457425
}
458426
};
459-
}, [scope, restoreFocus, contain]);
427+
}, [scope, restoreFocus]);
428+
429+
return restoreFocus ? nodeToRestoreRef : undefined;
460430
}
461431

462432
/**

0 commit comments

Comments
 (0)