Skip to content

Commit 273e828

Browse files
fix(4233): Fix useRestoreFocus logic (#4353)
Don't restore focus if focus never entered the scope. Fixes combobox reopening when using focus as a trigger
1 parent 24526a9 commit 273e828

File tree

3 files changed

+108
-3
lines changed

3 files changed

+108
-3
lines changed

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -568,7 +568,9 @@ function useRestoreFocus(scopeRef: RefObject<Element[]>, restoreFocus: boolean,
568568
let onFocus = () => {
569569
// If focusing an element in a child scope of the currently active scope, the child becomes active.
570570
// Moving out of the active scope to an ancestor is not allowed.
571-
if (!activeScope || isAncestorScope(activeScope, scopeRef)) {
571+
if ((!activeScope || isAncestorScope(activeScope, scopeRef)) &&
572+
isElementInScope(document.activeElement, scopeRef.current)
573+
) {
572574
activeScope = scopeRef;
573575
}
574576
};

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import {FocusScope} from '../';
1414
import {Meta} from '@storybook/react';
15-
import React, {ReactNode, useState} from 'react';
15+
import React, {ReactNode, useEffect, useState} from 'react';
1616
import ReactDOM from 'react-dom';
1717

1818
const dialogsRoot = 'dialogsRoot';
@@ -149,6 +149,41 @@ function FocusableFirstInScopeExample() {
149149
);
150150
}
151151

152+
function IgnoreRestoreFocusExample() {
153+
const [display, setDisplay] = useState(false);
154+
useEffect(() => {
155+
let handleKeyDown = (e) => {
156+
if (e.key === 'Escape') {
157+
setDisplay(false);
158+
}
159+
};
160+
document.body.addEventListener('keyup', handleKeyDown);
161+
return () => {
162+
document.body.removeEventListener('keyup', handleKeyDown);
163+
};
164+
}, []);
165+
166+
return (
167+
<div>
168+
<button type="button" onClick={() => setDisplay(state => !state)}>
169+
{display ? 'Close dialog 1' : 'Open dialog 1'}
170+
</button>
171+
<button type="button" onClick={() => setDisplay(state => !state)}>
172+
{display ? 'Close dialog 2' : 'Open dialog 2'}
173+
</button>
174+
{display &&
175+
<FocusScope restoreFocus>
176+
<div role="dialog">
177+
<input />
178+
<input />
179+
<input />
180+
</div>
181+
</FocusScope>
182+
}
183+
</div>
184+
);
185+
}
186+
152187
export const KeyboardNavigation = {
153188
render: Template,
154189
args: {isPortaled: false}
@@ -169,6 +204,10 @@ export const KeyboardNavigationInsidePortalNoContain = {
169204
args: {isPortaled: true, contain: false}
170205
};
171206

207+
export const IgnoreRestoreFocus = {
208+
render: () => <IgnoreRestoreFocusExample />
209+
};
210+
172211
export const FocusableFirstInScope = {
173212
render: () => <FocusableFirstInScopeExample />
174213
};

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

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {DialogContainer} from '@react-spectrum/dialog';
1616
import {FocusScope, useFocusManager} from '../';
1717
import {focusScopeTree} from '../src/FocusScope';
1818
import {Provider} from '@react-spectrum/provider';
19-
import React, {useState} from 'react';
19+
import React, {useEffect, useState} from 'react';
2020
import ReactDOM from 'react-dom';
2121
import {Example as StorybookExample} from '../stories/FocusScope.stories';
2222
import userEvent from '@testing-library/user-event';
@@ -693,6 +693,70 @@ describe('FocusScope', function () {
693693
await waitFor(() => expect(document.activeElement).toBe(focusable));
694694
});
695695
});
696+
697+
it('should not not restore focus when active element is outside the scope', function () {
698+
function Test() {
699+
const [display, setDisplay] = useState(false);
700+
useEffect(() => {
701+
let handleKeyDown = (e) => {
702+
if (e.key === 'Escape') {
703+
setDisplay(false);
704+
}
705+
};
706+
document.body.addEventListener('keyup', handleKeyDown);
707+
return () => {
708+
document.body.removeEventListener('keyup', handleKeyDown);
709+
};
710+
}, []);
711+
712+
return (
713+
<div>
714+
<button
715+
data-testid="button1"
716+
type="button"
717+
onClick={() => setDisplay((state) => !state)}>
718+
{display ? 'Close dialog' : 'Open dialog'}
719+
</button>
720+
<button
721+
data-testid="button2"
722+
type="button"
723+
onClick={() => setDisplay((state) => !state)}>
724+
{display ? 'Close dialog' : 'Open dialog'}
725+
</button>{' '}
726+
{display && (
727+
<FocusScope restoreFocus>
728+
<input data-testid="input1" />
729+
</FocusScope>
730+
)}
731+
</div>
732+
);
733+
}
734+
735+
let {getByTestId} = render(<Test />);
736+
let button1 = getByTestId('button1');
737+
let button2 = getByTestId('button2');
738+
userEvent.click(button1);
739+
act(() => {jest.runAllTimers();});
740+
expect(document.activeElement).toBe(button1);
741+
let input1 = getByTestId('input1');
742+
expect(input1).toBeVisible();
743+
744+
userEvent.click(button2);
745+
act(() => {jest.runAllTimers();});
746+
expect(document.activeElement).toBe(button2);
747+
expect(input1).not.toBeInTheDocument();
748+
749+
userEvent.click(button1);
750+
act(() => {jest.runAllTimers();});
751+
input1 = getByTestId('input1');
752+
expect(input1).toBeVisible();
753+
userEvent.tab();
754+
fireEvent.keyDown(document.activeElement, {key: 'Escape'});
755+
fireEvent.keyUp(document.activeElement, {key: 'Escape'});
756+
act(() => {jest.runAllTimers();});
757+
expect(document.activeElement).toBe(button2);
758+
expect(input1).not.toBeInTheDocument();
759+
});
696760
});
697761

698762
describe('auto focus', function () {

0 commit comments

Comments
 (0)