Skip to content

Commit a8fc9ee

Browse files
slye-stripesnowystingerLFDanLuktabors
authored
Use owner document in FocusScope (#5449)
* Use owner document in FocusScope * Fix eslint rule for @react-aria/focus * Ignore stories for eslint * Fix eslint rule * Address feedback * Add tests for focusSafely * Address feedback * Update packages/@react-aria/focus/test/focusSafely.test.js Co-authored-by: Kyle Taborski <[email protected]> * Update packages/@react-aria/focus/test/FocusScopeOwnerDocument.test.js Co-authored-by: Kyle Taborski <[email protected]> --------- Co-authored-by: Robert Snow <[email protected]> Co-authored-by: Daniel Lu <[email protected]> Co-authored-by: Kyle Taborski <[email protected]>
1 parent e105888 commit a8fc9ee

File tree

7 files changed

+487
-38
lines changed

7 files changed

+487
-38
lines changed

.eslintrc.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,23 @@ module.exports = {
6767
'jsdoc/require-description': OFF
6868
}
6969
}, {
70-
files: ['packages/@react-aria/interactions/**/*.ts', 'packages/@react-aria/interactions/**/*.tsx'],
70+
files: ['packages/@react-aria/focus/src/**/*.ts', 'packages/@react-aria/focus/src/**/*.tsx'],
71+
rules: {
72+
'no-restricted-globals': [
73+
ERROR,
74+
{
75+
'name': 'window',
76+
'message': 'Use getOwnerWindow from @react-aria/utils instead.'
77+
},
78+
{
79+
'name': 'document',
80+
'message': 'Use getOwnerDocument from @react-aria/utils instead.'
81+
}
82+
]
83+
}
84+
},
85+
{
86+
files: ['packages/@react-aria/interactions/src/**/*.ts', 'packages/@react-aria/interactions/src/**/*.tsx'],
7187
rules: {
7288
'no-restricted-globals': [
7389
WARN,

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

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@
1212

1313
import {FocusableElement} from '@react-types/shared';
1414
import {focusSafely} from './focusSafely';
15+
import {getOwnerDocument, useLayoutEffect} from '@react-aria/utils';
1516
import {isElementVisible} from './isElementVisible';
1617
import React, {ReactNode, RefObject, useContext, useEffect, useMemo, useRef} from 'react';
17-
import {useLayoutEffect} from '@react-aria/utils';
18-
1918

2019
export interface FocusScopeProps {
2120
/** The contents of the focus scope. */
@@ -134,7 +133,7 @@ export function FocusScope(props: FocusScopeProps) {
134133
// This needs to be an effect so that activeScope is updated after the FocusScope tree is complete.
135134
// It cannot be a useLayoutEffect because the parent of this node hasn't been attached in the tree yet.
136135
useEffect(() => {
137-
let activeElement = document.activeElement;
136+
const activeElement = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement;
138137
let scope: TreeNode | null = null;
139138

140139
if (isElementInScope(activeElement, scopeRef.current)) {
@@ -198,7 +197,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject<Element[]>): Focus
198197
focusNext(opts: FocusManagerOptions = {}) {
199198
let scope = scopeRef.current!;
200199
let {from, tabbable, wrap, accept} = opts;
201-
let node = from || document.activeElement!;
200+
let node = from || getOwnerDocument(scope[0]).activeElement!;
202201
let sentinel = scope[0].previousElementSibling!;
203202
let scopeRoot = getScopeRoot(scope);
204203
let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope);
@@ -216,7 +215,7 @@ function createFocusManagerForScope(scopeRef: React.RefObject<Element[]>): Focus
216215
focusPrevious(opts: FocusManagerOptions = {}) {
217216
let scope = scopeRef.current!;
218217
let {from, tabbable, wrap, accept} = opts;
219-
let node = from || document.activeElement!;
218+
let node = from || getOwnerDocument(scope[0]).activeElement!;
220219
let sentinel = scope[scope.length - 1].nextElementSibling!;
221220
let scopeRoot = getScopeRoot(scope);
222221
let walker = getFocusableTreeWalker(scopeRoot, {tabbable, accept}, scope);
@@ -311,13 +310,15 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain?: boolean)
311310
return;
312311
}
313312

313+
const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined);
314+
314315
// Handle the Tab key to contain focus within the scope
315316
let onKeyDown = (e) => {
316317
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey || !shouldContainFocus(scopeRef)) {
317318
return;
318319
}
319320

320-
let focusedElement = document.activeElement;
321+
let focusedElement = ownerDocument.activeElement;
321322
let scope = scopeRef.current;
322323
if (!scope || !isElementInScope(focusedElement, scope)) {
323324
return;
@@ -367,9 +368,9 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain?: boolean)
367368
}
368369
raf.current = requestAnimationFrame(() => {
369370
// Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
370-
if (document.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(document.activeElement, scopeRef)) {
371+
if (ownerDocument.activeElement && shouldContainFocus(scopeRef) && !isElementInChildScope(ownerDocument.activeElement, scopeRef)) {
371372
activeScope = scopeRef;
372-
if (document.body.contains(e.target)) {
373+
if (ownerDocument.body.contains(e.target)) {
373374
focusedNode.current = e.target;
374375
focusedNode.current?.focus();
375376
} else if (activeScope.current) {
@@ -379,13 +380,13 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain?: boolean)
379380
});
380381
};
381382

382-
document.addEventListener('keydown', onKeyDown, false);
383-
document.addEventListener('focusin', onFocus, false);
383+
ownerDocument.addEventListener('keydown', onKeyDown, false);
384+
ownerDocument.addEventListener('focusin', onFocus, false);
384385
scope?.forEach(element => element.addEventListener('focusin', onFocus, false));
385386
scope?.forEach(element => element.addEventListener('focusout', onBlur, false));
386387
return () => {
387-
document.removeEventListener('keydown', onKeyDown, false);
388-
document.removeEventListener('focusin', onFocus, false);
388+
ownerDocument.removeEventListener('keydown', onKeyDown, false);
389+
ownerDocument.removeEventListener('focusin', onFocus, false);
389390
scope?.forEach(element => element.removeEventListener('focusin', onFocus, false));
390391
scope?.forEach(element => element.removeEventListener('focusout', onBlur, false));
391392
};
@@ -488,7 +489,8 @@ function useAutoFocus(scopeRef: RefObject<Element[]>, autoFocus?: boolean) {
488489
useEffect(() => {
489490
if (autoFocusRef.current) {
490491
activeScope = scopeRef;
491-
if (!isElementInScope(document.activeElement, activeScope.current) && scopeRef.current) {
492+
const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined);
493+
if (!isElementInScope(ownerDocument.activeElement, activeScope.current) && scopeRef.current) {
492494
focusFirstInScope(scopeRef.current);
493495
}
494496
}
@@ -505,6 +507,7 @@ function useActiveScopeTracker(scopeRef: RefObject<Element[]>, restore?: boolean
505507
}
506508

507509
let scope = scopeRef.current;
510+
const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined);
508511

509512
let onFocus = (e) => {
510513
let target = e.target as Element;
@@ -515,10 +518,10 @@ function useActiveScopeTracker(scopeRef: RefObject<Element[]>, restore?: boolean
515518
}
516519
};
517520

518-
document.addEventListener('focusin', onFocus, false);
521+
ownerDocument.addEventListener('focusin', onFocus, false);
519522
scope?.forEach(element => element.addEventListener('focusin', onFocus, false));
520523
return () => {
521-
document.removeEventListener('focusin', onFocus, false);
524+
ownerDocument.removeEventListener('focusin', onFocus, false);
522525
scope?.forEach(element => element.removeEventListener('focusin', onFocus, false));
523526
};
524527
}, [scopeRef, restore, contain]);
@@ -539,12 +542,14 @@ function shouldRestoreFocus(scopeRef: ScopeRef) {
539542

540543
function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean, contain?: boolean) {
541544
// create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts.
542-
const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? document.activeElement as FocusableElement : null);
545+
// eslint-disable-next-line no-restricted-globals
546+
const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined).activeElement as FocusableElement : null);
543547

544548
// restoring scopes should all track if they are active regardless of contain, but contain already tracks it plus logic to contain the focus
545549
// restoring-non-containing scopes should only care if they become active so they can perform the restore
546550
useLayoutEffect(() => {
547551
let scope = scopeRef.current;
552+
const ownerDocument = getOwnerDocument(scope ? scope[0] : undefined);
548553
if (!restoreFocus || contain) {
549554
return;
550555
}
@@ -553,22 +558,24 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
553558
// If focusing an element in a child scope of the currently active scope, the child becomes active.
554559
// Moving out of the active scope to an ancestor is not allowed.
555560
if ((!activeScope || isAncestorScope(activeScope, scopeRef)) &&
556-
isElementInScope(document.activeElement, scopeRef.current)
561+
isElementInScope(ownerDocument.activeElement, scopeRef.current)
557562
) {
558563
activeScope = scopeRef;
559564
}
560565
};
561566

562-
document.addEventListener('focusin', onFocus, false);
567+
ownerDocument.addEventListener('focusin', onFocus, false);
563568
scope?.forEach(element => element.addEventListener('focusin', onFocus, false));
564569
return () => {
565-
document.removeEventListener('focusin', onFocus, false);
570+
ownerDocument.removeEventListener('focusin', onFocus, false);
566571
scope?.forEach(element => element.removeEventListener('focusin', onFocus, false));
567572
};
568573
// eslint-disable-next-line react-hooks/exhaustive-deps
569574
}, [scopeRef, contain]);
570575

571576
useLayoutEffect(() => {
577+
const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined);
578+
572579
if (!restoreFocus) {
573580
return;
574581
}
@@ -582,7 +589,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
582589
return;
583590
}
584591

585-
let focusedElement = document.activeElement as FocusableElement;
592+
let focusedElement = ownerDocument.activeElement as FocusableElement;
586593
if (!isElementInScope(focusedElement, scopeRef.current)) {
587594
return;
588595
}
@@ -593,13 +600,13 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
593600
let nodeToRestore = treeNode.nodeToRestore;
594601

595602
// Create a DOM tree walker that matches all tabbable elements
596-
let walker = getFocusableTreeWalker(document.body, {tabbable: true});
603+
let walker = getFocusableTreeWalker(ownerDocument.body, {tabbable: true});
597604

598605
// Find the next tabbable element after the currently focused element
599606
walker.currentNode = focusedElement;
600607
let nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as FocusableElement;
601608

602-
if (!nodeToRestore || !document.body.contains(nodeToRestore) || nodeToRestore === document.body) {
609+
if (!nodeToRestore || !ownerDocument.body.contains(nodeToRestore) || nodeToRestore === ownerDocument.body) {
603610
nodeToRestore = undefined;
604611
treeNode.nodeToRestore = undefined;
605612
}
@@ -632,18 +639,20 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
632639
};
633640

634641
if (!contain) {
635-
document.addEventListener('keydown', onKeyDown, true);
642+
ownerDocument.addEventListener('keydown', onKeyDown, true);
636643
}
637644

638645
return () => {
639646
if (!contain) {
640-
document.removeEventListener('keydown', onKeyDown, true);
647+
ownerDocument.removeEventListener('keydown', onKeyDown, true);
641648
}
642649
};
643650
}, [scopeRef, restoreFocus, contain]);
644651

645652
// useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously.
646653
useLayoutEffect(() => {
654+
const ownerDocument = getOwnerDocument(scopeRef.current ? scopeRef.current[0] : undefined);
655+
647656
if (!restoreFocus) {
648657
return;
649658
}
@@ -653,7 +662,6 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
653662
return;
654663
}
655664
treeNode.nodeToRestore = nodeToRestoreRef.current ?? undefined;
656-
657665
return () => {
658666
let treeNode = focusScopeTree.getTreeNode(scopeRef);
659667
if (!treeNode) {
@@ -667,19 +675,19 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
667675
&& nodeToRestore
668676
&& (
669677
// eslint-disable-next-line react-hooks/exhaustive-deps
670-
isElementInScope(document.activeElement, scopeRef.current)
671-
|| (document.activeElement === document.body && shouldRestoreFocus(scopeRef))
678+
isElementInScope(ownerDocument.activeElement, scopeRef.current)
679+
|| (ownerDocument.activeElement === ownerDocument.body && shouldRestoreFocus(scopeRef))
672680
)
673681
) {
674682
// freeze the focusScopeTree so it persists after the raf, otherwise during unmount nodes are removed from it
675683
let clonedTree = focusScopeTree.clone();
676684
requestAnimationFrame(() => {
677685
// Only restore focus if we've lost focus to the body, the alternative is that focus has been purposefully moved elsewhere
678-
if (document.activeElement === document.body) {
686+
if (ownerDocument.activeElement === ownerDocument.body) {
679687
// look up the tree starting with our scope to find a nodeToRestore still in the DOM
680688
let treeNode = clonedTree.getTreeNode(scopeRef);
681689
while (treeNode) {
682-
if (treeNode.nodeToRestore && document.body.contains(treeNode.nodeToRestore)) {
690+
if (treeNode.nodeToRestore && treeNode.nodeToRestore.isConnected) {
683691
focusElement(treeNode.nodeToRestore);
684692
return;
685693
}
@@ -709,7 +717,7 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus?: boolean,
709717
*/
710718
export function getFocusableTreeWalker(root: Element, opts?: FocusManagerOptions, scope?: Element[]) {
711719
let selector = opts?.tabbable ? TABBABLE_ELEMENT_SELECTOR : FOCUSABLE_ELEMENT_SELECTOR;
712-
let walker = document.createTreeWalker(
720+
let walker = getOwnerDocument(root).createTreeWalker(
713721
root,
714722
NodeFilter.SHOW_ELEMENT,
715723
{
@@ -750,7 +758,7 @@ export function createFocusManager(ref: RefObject<Element>, defaultOptions: Focu
750758
return null;
751759
}
752760
let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts;
753-
let node = from || document.activeElement;
761+
let node = from || getOwnerDocument(root).activeElement;
754762
let walker = getFocusableTreeWalker(root, {tabbable, accept});
755763
if (root.contains(node)) {
756764
walker.currentNode = node!;
@@ -771,7 +779,7 @@ export function createFocusManager(ref: RefObject<Element>, defaultOptions: Focu
771779
return null;
772780
}
773781
let {from, tabbable = defaultOptions.tabbable, wrap = defaultOptions.wrap, accept = defaultOptions.accept} = opts;
774-
let node = from || document.activeElement;
782+
let node = from || getOwnerDocument(root).activeElement;
775783
let walker = getFocusableTreeWalker(root, {tabbable, accept});
776784
if (root.contains(node)) {
777785
walker.currentNode = node!;

packages/@react-aria/focus/src/focusSafely.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {FocusableElement} from '@react-types/shared';
14-
import {focusWithoutScrolling, runAfterTransition} from '@react-aria/utils';
14+
import {focusWithoutScrolling, getOwnerDocument, runAfterTransition} from '@react-aria/utils';
1515
import {getInteractionModality} from '@react-aria/interactions';
1616

1717
/**
@@ -24,11 +24,12 @@ export function focusSafely(element: FocusableElement) {
2424
// the page before shifting focus. This avoids issues with VoiceOver on iOS
2525
// causing the page to scroll when moving focus if the element is transitioning
2626
// from off the screen.
27+
const ownerDocument = getOwnerDocument(element);
2728
if (getInteractionModality() === 'virtual') {
28-
let lastFocusedElement = document.activeElement;
29+
let lastFocusedElement = ownerDocument.activeElement;
2930
runAfterTransition(() => {
3031
// If focus did not move and the element is still in the document, focus it.
31-
if (document.activeElement === lastFocusedElement && document.contains(element)) {
32+
if (ownerDocument.activeElement === lastFocusedElement && element.isConnected) {
3233
focusWithoutScrolling(element);
3334
}
3435
});

packages/@react-aria/focus/src/isElementVisible.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {getOwnerWindow} from '@react-aria/utils';
14+
1315
function isStyleVisible(element: Element) {
14-
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
16+
const windowObject = getOwnerWindow(element);
17+
if (!(element instanceof windowObject.HTMLElement) && !(element instanceof windowObject.SVGElement)) {
1518
return false;
1619
}
1720

packages/@react-aria/focus/test/FocusScope.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,8 @@ describe('FocusScope', function () {
287287
expect(document.activeElement).toBe(input2);
288288
});
289289

290-
it('uses document.activeElement instead of e.relatedTarget on blur to determine if focus is still in scope', function () {
290+
// This test setup is a bit contrived to just purely simulate the blur/focus events that would happen in a case like this
291+
it('focus properly moves into child iframe on click', function () {
291292
let {getByTestId} = render(
292293
<div>
293294
<FocusScope contain>

0 commit comments

Comments
 (0)