Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/components/Popover/Popover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,53 @@ describe('Popover', () => {
);
});

it('restores focus after Escape closes a popover that moved focus', async () => {
render(
<Popover content={<button type="button">Inside</button>} label="Actions">
<Button label="Open" />
</Popover>,
);

const trigger = screen.getByRole('button', {name: 'Open'});
trigger.focus();
fireEvent.click(trigger);

await waitFor(() =>
expect(
screen.getByRole('button', {hidden: true, name: 'Inside'}),
).toHaveFocus(),
);

fireEvent.keyDown(document, {key: 'Escape'});

expect(trigger).toHaveFocus();
});

it('leaves focus alone when opening did not move focus', async () => {
render(
<>
<Popover
content={<div>Nothing focusable</div>}
hasCloseButton={false}
label="Actions">
<Button label="Open" />
</Popover>
<Button label="Elsewhere" />
</>,
);

const trigger = screen.getByRole('button', {name: 'Open'});
trigger.focus();
fireEvent.click(trigger);
await nextAnimationFrame();

const elsewhere = screen.getByRole('button', {name: 'Elsewhere'});
elsewhere.focus();
fireEvent(getPopoverElement(), closeToggleEvent());

expect(elsewhere).toHaveFocus();
});

it('does not move focus when hasAutoFocus is false', async () => {
showPopoverMock.mockClear();

Expand Down
44 changes: 41 additions & 3 deletions src/components/Popover/usePopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ export function usePopover({
layerId,
}: UsePopoverOptions = {}): UsePopoverReturn {
const skipAutoFocusRef = useRef(false);
const autoFocusFrameRef = useRef<number | null>(null);
const restoreFocusTargetRef = useRef<HTMLElement | null>(null);

const restoreFocus = useCallback(() => {
if (autoFocusFrameRef.current != null) {
cancelAnimationFrame(autoFocusFrameRef.current);
autoFocusFrameRef.current = null;
}

restoreFocusTargetRef.current?.focus();
restoreFocusTargetRef.current = null;
}, []);

// Guards against a light-dismiss close immediately re-opening the popover.
// When the trigger is clicked while the popover is open, the browser's native
// light dismiss closes it (firing `onHide`) *before* the trigger's own click
Expand All @@ -156,8 +169,9 @@ export function usePopover({
requestAnimationFrame(() => {
isDismissingRef.current = false;
});
restoreFocus();
onHide?.();
}, [onHide]);
}, [onHide, restoreFocus]);

const layer = useLayer({
isDismissable,
Expand All @@ -177,9 +191,33 @@ export function usePopover({
}

if (hasAutoFocus && !skipAutoFocusRef.current) {
requestAnimationFrame(() => focusFirst());
const previouslyFocusedElement =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
autoFocusFrameRef.current = requestAnimationFrame(() => {
autoFocusFrameRef.current = null;
const activeElementBeforeFocus = document.activeElement;
focusFirst();
const activeElementAfterFocus = document.activeElement;

if (
previouslyFocusedElement != null &&
activeElementAfterFocus !== activeElementBeforeFocus &&
contentRef.current?.contains(activeElementAfterFocus) === true
) {
restoreFocusTargetRef.current = previouslyFocusedElement;
}
});
}
}, [focusFirst, hasAutoFocus, layer.isOpen]);

return () => {
if (autoFocusFrameRef.current != null) {
cancelAnimationFrame(autoFocusFrameRef.current);
autoFocusFrameRef.current = null;
}
};
}, [contentRef, focusFirst, hasAutoFocus, layer.isOpen]);

const show = useCallback(
(options?: {isAutoFocusSkipped?: boolean}) => {
Expand Down