Skip to content

Commit 7522547

Browse files
committed
Avoid restoring focus when clicking on focusable element outside Popover
Fix an issue where clicking on a focusable element outside of an open popover, other than the previously focused element, would cause focus to be reverted back to the previously focused element. When native popovers are used, they handle focus restoration for us [^1], so we don't need to do anything when the popover is closed. Setting `restoreFocusOnClose` to `false` didn't actually work in this case. When non-native popovers are used, focus reverts to `document.body` when the popover is hidden, unless the user clicked on a different focusable element outside the popover. Only when it has reverted to `document.body` do we want to restore focus. Fixes #1906 [^1]: See `focusPreviousElement` references in https://html.spec.whatwg.org/multipage/popover.html#dom-hidepopover
1 parent 9a7436f commit 7522547

File tree

2 files changed

+100
-26
lines changed

2 files changed

+100
-26
lines changed

src/components/feedback/Popover.tsx

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,9 @@ export type PopoverProps = {
248248
/**
249249
* Determines if focus should be restored when the popover is closed.
250250
* Defaults to true.
251+
*
252+
* @deprecated - Setting this prop to `false` does not work if native popovers
253+
* are used. This prop will be removed entirely in future.
251254
*/
252255
restoreFocusOnClose?: boolean;
253256

@@ -261,6 +264,61 @@ export type PopoverProps = {
261264
onScroll?: JSX.HTMLAttributes<HTMLElement>['onScroll'];
262265
};
263266

267+
type RestoreFocusOptions = {
268+
/** True if the popover is open. */
269+
open: boolean;
270+
271+
/** Ref for the popover element. */
272+
popoverRef: { current: HTMLElement | null };
273+
};
274+
275+
/**
276+
* Restore focus to the previously active element when a popover is closed.
277+
*/
278+
function useRestoreFocusOnClose({ popoverRef, open }: RestoreFocusOptions) {
279+
useLayoutEffect(() => {
280+
const container = popoverRef.current;
281+
const restoreFocusTo = open
282+
? (document.activeElement as HTMLElement)
283+
: null;
284+
285+
if (!container || !restoreFocusTo) {
286+
return () => {};
287+
}
288+
289+
return () => {
290+
// When a popover is opened and then closed, there are several
291+
// possibilities for what happens to the focus:
292+
//
293+
// 1. The focus may be unchanged from before the popover was opened.
294+
//
295+
// 2. The focus may have moved into the popover when it was opened, and
296+
// then back to either the previously focused element or the body when
297+
// it was closed.
298+
//
299+
// When a native popover is closed via `togglePopover` or `hidePopover`,
300+
// focus will revert to the element that was focused at the time the
301+
// popover was shown. See https://html.spec.whatwg.org/multipage/popover.html#dom-hidepopover.
302+
//
303+
// 3. The user may have clicked an element outside the popover, focusing
304+
// that element and causing the popover to close.
305+
//
306+
// From the above cases, we only need to restore focus if it is still
307+
// inside the popover, or focus reverted to the document body.
308+
const currentFocus = document.activeElement;
309+
if (
310+
currentFocus &&
311+
!container.contains(currentFocus) &&
312+
currentFocus !== document.body
313+
) {
314+
return;
315+
}
316+
317+
restoreFocusTo.focus();
318+
};
319+
}, [popoverRef, open]);
320+
}
321+
264322
export default function Popover({
265323
anchorElementRef,
266324
children,
@@ -269,7 +327,6 @@ export default function Popover({
269327
align = 'left',
270328
classes,
271329
variant = 'panel',
272-
restoreFocusOnClose = true,
273330
onScroll,
274331
/* eslint-disable-next-line no-prototype-builtins */
275332
asNativePopover = HTMLElement.prototype.hasOwnProperty('popover'),
@@ -283,20 +340,11 @@ export default function Popover({
283340
asNativePopover,
284341
align === 'right',
285342
);
286-
287343
useOnClose(popoverRef, anchorElementRef, onClose, open, asNativePopover);
288-
289-
useLayoutEffect(() => {
290-
const restoreFocusTo = open
291-
? (document.activeElement as HTMLElement)
292-
: null;
293-
294-
return () => {
295-
if (restoreFocusOnClose && restoreFocusTo) {
296-
restoreFocusTo.focus();
297-
}
298-
};
299-
}, [open, restoreFocusOnClose]);
344+
useRestoreFocusOnClose({
345+
open,
346+
popoverRef: popoverRef,
347+
});
300348

301349
return (
302350
<div

src/components/feedback/test/Popover-test.js

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ function TestComponent({ children, ...rest }) {
99

1010
return (
1111
<div className="relative">
12+
{/* Focusable element that is not the popover trigger. */}
13+
<input data-testid="test-input" />
1214
<button
1315
data-testid="toggle-button"
1416
className="p-2"
@@ -98,20 +100,14 @@ describe('Popover', () => {
98100

99101
[
100102
{
101-
restoreFocusOnClose: undefined, // Defaults to true
102-
expectedFocusAfterClose: wrapper => getToggleButton(wrapper).getDOMNode(),
103-
},
104-
{
105-
restoreFocusOnClose: true,
106-
expectedFocusAfterClose: wrapper => getToggleButton(wrapper).getDOMNode(),
103+
asNativePopover: true,
107104
},
108105
{
109-
restoreFocusOnClose: false,
110-
expectedFocusAfterClose: () => document.body,
106+
asNativePopover: false,
111107
},
112-
].forEach(({ restoreFocusOnClose, expectedFocusAfterClose }) => {
113-
it('restores focus to toggle button after closing popover', () => {
114-
const wrapper = createComponent({ restoreFocusOnClose });
108+
].forEach(({ asNativePopover }) => {
109+
it('restores focus to previously focused element when popover is closed', () => {
110+
const wrapper = createComponent({ asNativePopover });
115111

116112
// Focus toggle button before opening popover
117113
getToggleButton(wrapper).getDOMNode().focus();
@@ -121,13 +117,43 @@ describe('Popover', () => {
121117
// Focus something else before closing the popover
122118
const innerButton = wrapper.find('[data-testid="inner-button"]');
123119
innerButton.getDOMNode().focus();
120+
assert.notEqual(
121+
document.activeElement,
122+
getToggleButton(wrapper).getDOMNode(),
123+
);
124+
124125
// Close the popover with a different button inside the popover itself
125126
innerButton.simulate('click');
126127
assert.isFalse(getPopover(wrapper).prop('open'));
127128

128129
// After closing popover, the focus should have returned to the toggle
129130
// button, which was the last focused element
130-
assert.equal(document.activeElement, expectedFocusAfterClose(wrapper));
131+
assert.equal(
132+
document.activeElement,
133+
getToggleButton(wrapper).getDOMNode(),
134+
);
135+
});
136+
137+
it("doesn't restore focus if user clicked on focusable element outside popover", () => {
138+
const wrapper = createComponent({ asNativePopover });
139+
140+
// Focus toggle button before opening popover
141+
getToggleButton(wrapper).getDOMNode().focus();
142+
togglePopover(wrapper);
143+
assert.isTrue(getPopover(wrapper).prop('open'));
144+
145+
// Focus a control outside the popover
146+
const input = wrapper.find('[data-testid="test-input"]').getDOMNode();
147+
input.focus();
148+
assert.equal(document.activeElement, input);
149+
150+
// Close popover
151+
const innerButton = wrapper.find('[data-testid="inner-button"]');
152+
innerButton.simulate('click');
153+
assert.isFalse(getPopover(wrapper).prop('open'));
154+
155+
// Check that input remains focused
156+
assert.equal(document.activeElement, input);
131157
});
132158
});
133159

0 commit comments

Comments
 (0)