Skip to content

Commit bef1b1d

Browse files
authored
Fix focus scope with changing children (#2791)
* Fix focus scope with changing children Keep focus scope up to date with changing children and auto focus children * story change to make more specific to observed cases
1 parent bf23cea commit bef1b1d

File tree

3 files changed

+179
-32
lines changed

3 files changed

+179
-32
lines changed

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -387,15 +387,16 @@ function useAutoFocus(scopeRef: RefObject<HTMLElement[]>, autoFocus: boolean) {
387387
}
388388

389389
function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boolean, contain: boolean) {
390+
// create a ref during render instead of useLayoutEffect so the active element is saved before a child with autoFocus=true mounts.
391+
const nodeToRestoreRef = useRef(typeof document !== 'undefined' ? document.activeElement as HTMLElement : null);
392+
390393
// useLayoutEffect instead of useEffect so the active element is saved synchronously instead of asynchronously.
391394
useLayoutEffect(() => {
395+
let nodeToRestore = nodeToRestoreRef.current;
392396
if (!restoreFocus) {
393397
return;
394398
}
395399

396-
let scope = scopeRef.current;
397-
let nodeToRestore = document.activeElement as HTMLElement;
398-
399400
// Handle the Tab key so that tabbing out of the scope goes to the next element
400401
// after the node that had focus when the scope mounted. This is important when
401402
// using portals for overlays, so that focus goes to the expected element when
@@ -406,7 +407,7 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
406407
}
407408

408409
let focusedElement = document.activeElement as HTMLElement;
409-
if (!isElementInScope(focusedElement, scope)) {
410+
if (!isElementInScope(focusedElement, scopeRef.current)) {
410411
return;
411412
}
412413

@@ -423,13 +424,13 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
423424

424425
// If there is no next element, or it is outside the current scope, move focus to the
425426
// next element after the node to restore to instead.
426-
if ((!nextElement || !isElementInScope(nextElement, scope)) && nodeToRestore) {
427+
if ((!nextElement || !isElementInScope(nextElement, scopeRef.current)) && nodeToRestore) {
427428
walker.currentNode = nodeToRestore;
428429

429430
// Skip over elements within the scope, in case the scope immediately follows the node to restore.
430431
do {
431432
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
432-
} while (isElementInScope(nextElement, scope));
433+
} while (isElementInScope(nextElement, scopeRef.current));
433434

434435
e.preventDefault();
435436
e.stopPropagation();
@@ -457,7 +458,7 @@ function useRestoreFocus(scopeRef: RefObject<HTMLElement[]>, restoreFocus: boole
457458
document.removeEventListener('keydown', onKeyDown, true);
458459
}
459460

460-
if (restoreFocus && nodeToRestore && isElementInScope(document.activeElement, scope)) {
461+
if (restoreFocus && nodeToRestore && isElementInScope(document.activeElement, scopeRef.current)) {
461462
requestAnimationFrame(() => {
462463
if (document.body.contains(nodeToRestore)) {
463464
focusElement(nodeToRestore);

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

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import ReactDOM from 'react-dom';
1818
const dialogsRoot = 'dialogsRoot';
1919

2020
interface StoryProps {
21-
usePortal: boolean
21+
usePortal: boolean,
22+
contain: boolean
2223
}
2324

2425
const meta: Meta<StoryProps> = {
@@ -33,7 +34,7 @@ const meta: Meta<StoryProps> = {
3334

3435
export default meta;
3536

36-
const Template = (): Story<StoryProps> => ({usePortal}) => <Example usePortal={usePortal} />;
37+
const Template = (): Story<StoryProps> => ({usePortal, contain = true}) => <Example usePortal={usePortal} contain={contain} />;
3738

3839
function MaybePortal({children, usePortal}: { children: ReactNode, usePortal: boolean}) {
3940
if (!usePortal) {
@@ -46,35 +47,47 @@ function MaybePortal({children, usePortal}: { children: ReactNode, usePortal: bo
4647
);
4748
}
4849

49-
function NestedDialog({onClose, usePortal}: {onClose: VoidFunction, usePortal: boolean}) {
50+
function NestedDialog({onClose, usePortal, contain}: {onClose: VoidFunction, usePortal: boolean, contain: boolean}) {
5051
let [open, setOpen] = useState(false);
52+
let [showNew, setShowNew] = useState(false);
53+
let onKeyDown = (e) => {
54+
if (e.key === 'Escape') {
55+
e.stopPropagation();
56+
onClose();
57+
}
58+
};
5159

5260
return (
5361
<MaybePortal usePortal={usePortal}>
54-
<FocusScope contain restoreFocus autoFocus>
55-
<div>
56-
<input />
57-
58-
<input />
59-
60-
<input />
61-
62-
<button type="button" onClick={() => setOpen(true)}>
63-
Open dialog
64-
</button>
65-
66-
<button type="button" onClick={onClose}>
67-
close
68-
</button>
69-
70-
{open && <NestedDialog onClose={() => setOpen(false)} usePortal={usePortal} />}
71-
</div>
62+
<FocusScope contain={contain} restoreFocus autoFocus>
63+
{!showNew && (
64+
<div role="dialog" onKeyDown={onKeyDown}>
65+
<input />
66+
<input />
67+
<input />
68+
<button onClick={() => setShowNew(true)}>replace focusscope children</button>
69+
<button type="button" onClick={() => setOpen(true)}>
70+
Open dialog
71+
</button>
72+
<button type="button" onClick={onClose}>
73+
close
74+
</button>
75+
{open && <NestedDialog contain={contain} onClose={() => setOpen(false)} usePortal={usePortal} />}
76+
</div>
77+
)}
78+
{showNew && (
79+
<div role="dialog" onKeyDown={onKeyDown}>
80+
<input />
81+
<input autoFocus />
82+
<input />
83+
</div>
84+
)}
7285
</FocusScope>
7386
</MaybePortal>
7487
);
7588
}
7689

77-
function Example({usePortal}: StoryProps) {
90+
function Example({usePortal, contain}: StoryProps) {
7891
let [open, setOpen] = useState(false);
7992

8093
return (
@@ -84,8 +97,8 @@ function Example({usePortal}: StoryProps) {
8497
<button type="button" onClick={() => setOpen(true)}>
8598
Open dialog
8699
</button>
87-
88-
{open && <NestedDialog onClose={() => setOpen(false)} usePortal={usePortal} />}
100+
<input />
101+
{open && <NestedDialog onClose={() => setOpen(false)} usePortal={usePortal} contain={contain} />}
89102

90103
<div id={dialogsRoot} />
91104
</div>
@@ -97,3 +110,9 @@ KeyboardNavigation.args = {usePortal: false};
97110

98111
export const KeyboardNavigationInsidePortal = Template().bind({});
99112
KeyboardNavigationInsidePortal.args = {usePortal: true};
113+
114+
export const KeyboardNavigationNoContain = Template().bind({});
115+
KeyboardNavigationNoContain.args = {usePortal: false, contain: false};
116+
117+
export const KeyboardNavigationInsidePortalNoContain = Template().bind({});
118+
KeyboardNavigationInsidePortalNoContain.args = {usePortal: true, contain: false};

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

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,134 @@ describe('FocusScope', function () {
331331
expect(document.activeElement).toBe(outside);
332332
});
333333

334-
it('should move focus to the next element after the previously focused node on Tab', function () {
334+
it('should restore focus to the previously focused node after a child with autoFocus unmounts', function () {
335+
function Test({show}) {
336+
return (
337+
<div>
338+
<input data-testid="outside" />
339+
{show &&
340+
<FocusScope restoreFocus>
341+
<input data-testid="input1" />
342+
<input data-testid="input2" autoFocus />
343+
<input data-testid="input3" />
344+
</FocusScope>
345+
}
346+
</div>
347+
);
348+
}
349+
350+
let {getByTestId, rerender} = render(<Test />);
351+
352+
let outside = getByTestId('outside');
353+
act(() => {outside.focus();});
354+
355+
rerender(<Test show />);
356+
357+
let input2 = getByTestId('input2');
358+
expect(document.activeElement).toBe(input2);
359+
360+
rerender(<Test />);
361+
362+
expect(document.activeElement).toBe(outside);
363+
});
364+
365+
it('should move focus after the previously focused node when tabbing away from a scope with autoFocus', function () {
366+
function Test({show}) {
367+
return (
368+
<div>
369+
<input data-testid="before" />
370+
<input data-testid="outside" />
371+
<input data-testid="after" />
372+
{show &&
373+
<FocusScope restoreFocus>
374+
<input data-testid="input1" />
375+
<input data-testid="input2" />
376+
<input data-testid="input3" autoFocus />
377+
</FocusScope>
378+
}
379+
</div>
380+
);
381+
}
382+
383+
let {getByTestId, rerender} = render(<Test />);
384+
385+
let outside = getByTestId('outside');
386+
act(() => {outside.focus();});
387+
388+
rerender(<Test show />);
389+
390+
let input3 = getByTestId('input3');
391+
expect(document.activeElement).toBe(input3);
392+
393+
userEvent.tab();
394+
expect(document.activeElement).toBe(getByTestId('after'));
395+
});
396+
397+
it('should move focus before the previously focused node when tabbing away from a scope with Shift+Tab', function () {
398+
function Test({show}) {
399+
return (
400+
<div>
401+
<input data-testid="before" />
402+
<input data-testid="outside" />
403+
<input data-testid="after" />
404+
{show &&
405+
<FocusScope restoreFocus>
406+
<input data-testid="input1" autoFocus />
407+
<input data-testid="input2" />
408+
<input data-testid="input3" />
409+
</FocusScope>
410+
}
411+
</div>
412+
);
413+
}
414+
415+
let {getByTestId, rerender} = render(<Test />);
416+
417+
let outside = getByTestId('outside');
418+
act(() => {outside.focus();});
419+
420+
rerender(<Test show />);
421+
422+
let input1 = getByTestId('input1');
423+
expect(document.activeElement).toBe(input1);
424+
425+
userEvent.tab({shift: true});
426+
expect(document.activeElement).toBe(getByTestId('before'));
427+
});
428+
429+
it('should restore focus to the previously focused node after children change', function () {
430+
function Test({show, showChild}) {
431+
return (
432+
<div>
433+
<input data-testid="outside" />
434+
{show &&
435+
<FocusScope restoreFocus autoFocus>
436+
<input data-testid="input1" />
437+
{showChild && <input data-testid="dynamic" />}
438+
</FocusScope>
439+
}
440+
</div>
441+
);
442+
}
443+
444+
let {getByTestId, rerender} = render(<Test />);
445+
446+
let outside = getByTestId('outside');
447+
act(() => {outside.focus();});
448+
449+
rerender(<Test show />);
450+
rerender(<Test show showChild />);
451+
452+
let dynamic = getByTestId('dynamic');
453+
act(() => {dynamic.focus();});
454+
expect(document.activeElement).toBe(dynamic);
455+
456+
rerender(<Test />);
457+
458+
expect(document.activeElement).toBe(outside);
459+
});
460+
461+
it('should move focus to the element after the previously focused node on Tab', function () {
335462
function Test({show}) {
336463
return (
337464
<div>

0 commit comments

Comments
 (0)