diff --git a/src/components/Popover/Popover.test.tsx b/src/components/Popover/Popover.test.tsx
index 5785b55..93a6c31 100644
--- a/src/components/Popover/Popover.test.tsx
+++ b/src/components/Popover/Popover.test.tsx
@@ -434,6 +434,53 @@ describe('Popover', () => {
);
});
+ it('restores focus after Escape closes a popover that moved focus', async () => {
+ render(
+ Inside} label="Actions">
+
+ ,
+ );
+
+ 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(
+ <>
+ Nothing focusable}
+ hasCloseButton={false}
+ label="Actions">
+
+
+
+ >,
+ );
+
+ 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();
diff --git a/src/components/Popover/usePopover.tsx b/src/components/Popover/usePopover.tsx
index a7081e8..1ddd8c5 100644
--- a/src/components/Popover/usePopover.tsx
+++ b/src/components/Popover/usePopover.tsx
@@ -142,6 +142,19 @@ export function usePopover({
layerId,
}: UsePopoverOptions = {}): UsePopoverReturn {
const skipAutoFocusRef = useRef(false);
+ const autoFocusFrameRef = useRef(null);
+ const restoreFocusTargetRef = useRef(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
@@ -156,8 +169,9 @@ export function usePopover({
requestAnimationFrame(() => {
isDismissingRef.current = false;
});
+ restoreFocus();
onHide?.();
- }, [onHide]);
+ }, [onHide, restoreFocus]);
const layer = useLayer({
isDismissable,
@@ -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}) => {