Skip to content

Commit 31a44c7

Browse files
authored
[popover][menu] De-duplicate focus guard handlers for popover and menu triggers (#4522)
1 parent f06a277 commit 31a44c7

4 files changed

Lines changed: 162 additions & 123 deletions

File tree

packages/react/src/menu/root/MenuRoot.test.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,65 @@ describe('<Menu.Root />', () => {
960960
);
961961
});
962962

963+
describe('focus guards', () => {
964+
it('closes the menu and moves focus to the next element when tabbing forward from the open menu', async () => {
965+
const { user } = await render(
966+
<div>
967+
<input />
968+
<TestMenu rootProps={{ modal: false }} />
969+
<input data-testid="after" />
970+
</div>,
971+
);
972+
973+
const trigger = screen.getByRole('button', { name: 'Toggle' });
974+
await user.click(trigger);
975+
976+
await screen.findByTestId('menu');
977+
978+
const menuItem = screen.getByTestId('item-1');
979+
await act(async () => {
980+
menuItem.focus();
981+
});
982+
983+
await user.tab();
984+
985+
expect(screen.getByTestId('after')).toHaveFocus();
986+
await waitFor(() => {
987+
expect(screen.queryByTestId('menu')).toBe(null);
988+
});
989+
});
990+
991+
it('closes the menu and moves focus to the trigger when shift-tabbing from the open menu', async () => {
992+
const { user } = await render(
993+
<div>
994+
<input data-testid="before" />
995+
<TestMenu />
996+
<input />
997+
</div>,
998+
);
999+
1000+
const trigger = screen.getByRole('button', { name: 'Toggle' });
1001+
await user.click(trigger);
1002+
1003+
await screen.findByTestId('menu');
1004+
1005+
const menuItem = screen.getByTestId('item-1');
1006+
await act(async () => {
1007+
menuItem.focus();
1008+
});
1009+
1010+
await user.keyboard('{Shift>}{Tab}{/Shift}');
1011+
1012+
await waitFor(() => {
1013+
expect(trigger).toHaveFocus();
1014+
});
1015+
1016+
await waitFor(() => {
1017+
expect(screen.queryByTestId('menu')).toBe(null);
1018+
});
1019+
});
1020+
});
1021+
9631022
describe('prop: closeParentOnEsc', () => {
9641023
it('does not close the parent menu when the Escape key is pressed by default', async () => {
9651024
const { user } = await render(<TestMenu />);

packages/react/src/menu/trigger/MenuTrigger.tsx

Lines changed: 4 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use client';
22
import * as React from 'react';
3-
import * as ReactDOM from 'react-dom';
43
import { useTimeout } from '@base-ui/utils/useTimeout';
54
import { ownerDocument } from '@base-ui/utils/owner';
65
import { fastComponentRef } from '@base-ui/utils/fastHooks';
@@ -18,14 +17,7 @@ import {
1817
useFloatingParentNodeId,
1918
} from '../../floating-ui-react';
2019
import { FloatingTreeStore } from '../../floating-ui-react/components/FloatingTreeStore';
21-
import {
22-
contains,
23-
type FocusableElement,
24-
getNextTabbable,
25-
getTabbableAfterElement,
26-
getTabbableBeforeElement,
27-
isOutsideEvent,
28-
} from '../../floating-ui-react/utils';
20+
import { contains } from '../../floating-ui-react/utils';
2921
import { useMenuRootContext } from '../root/MenuRootContext';
3022
import { pressableTriggerOpenStateMapping } from '../../utils/popupStateMapping';
3123
import { useRenderElement } from '../../utils/useRenderElement';
@@ -36,6 +28,7 @@ import { CompositeItem } from '../../composite/item/CompositeItem';
3628
import { useCompositeRootContext } from '../../composite/root/CompositeRootContext';
3729
import { findRootOwnerId } from '../utils/findRootOwnerId';
3830
import { useTriggerDataForwarding } from '../../utils/popups';
31+
import { useTriggerFocusGuards } from '../../utils/popups/useTriggerFocusGuards';
3932
import { useBaseUiId } from '../../utils/useBaseUiId';
4033
import { REASONS } from '../../utils/reasons';
4134
import { useMixedToggleClickHandler } from '../../utils/useMixedToggleClickHandler';
@@ -45,7 +38,6 @@ import { useMenubarContext } from '../../menubar/MenubarContext';
4538
import { MenuParent } from '../root/MenuRoot';
4639
import { PATIENT_CLICK_THRESHOLD } from '../../utils/constants';
4740
import { FocusGuard } from '../../utils/FocusGuard';
48-
import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails';
4941

5042
const BOUNDARY_OFFSET = 2;
5143

@@ -255,57 +247,8 @@ export const MenuTrigger = fastComponentRef(function MenuTrigger(
255247
getButtonProps,
256248
];
257249

258-
const preFocusGuardRef = React.useRef<HTMLElement>(null);
259-
260-
const handlePreFocusGuardFocus = useStableCallback((event: React.FocusEvent) => {
261-
ReactDOM.flushSync(() => {
262-
store.setOpen(
263-
false,
264-
createChangeEventDetails(
265-
REASONS.focusOut,
266-
event.nativeEvent,
267-
event.currentTarget as HTMLElement,
268-
),
269-
);
270-
});
271-
272-
const previousTabbable: FocusableElement | null = getTabbableBeforeElement(
273-
preFocusGuardRef.current,
274-
);
275-
previousTabbable?.focus();
276-
});
277-
278-
const handleFocusTargetFocus = useStableCallback((event: React.FocusEvent) => {
279-
const currentPositionerElement = store.select('positionerElement');
280-
if (currentPositionerElement && isOutsideEvent(event, currentPositionerElement)) {
281-
store.context.beforeContentFocusGuardRef.current?.focus();
282-
} else {
283-
ReactDOM.flushSync(() => {
284-
store.setOpen(
285-
false,
286-
createChangeEventDetails(
287-
REASONS.focusOut,
288-
event.nativeEvent,
289-
event.currentTarget as HTMLElement,
290-
),
291-
);
292-
});
293-
294-
let nextTabbable = getTabbableAfterElement(
295-
store.context.triggerFocusTargetRef.current || triggerElementRef.current,
296-
);
297-
298-
while (nextTabbable !== null && contains(currentPositionerElement, nextTabbable)) {
299-
const prevTabbable = nextTabbable;
300-
nextTabbable = getNextTabbable(nextTabbable);
301-
if (nextTabbable === prevTabbable) {
302-
break;
303-
}
304-
}
305-
306-
nextTabbable?.focus();
307-
}
308-
});
250+
const { preFocusGuardRef, handlePreFocusGuardFocus, handleFocusTargetFocus } =
251+
useTriggerFocusGuards(store, triggerElementRef);
309252

310253
const element = useRenderElement('button', componentProps, {
311254
enabled: !isInMenubar,

packages/react/src/popover/trigger/PopoverTrigger.tsx

Lines changed: 3 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
'use client';
22
import * as React from 'react';
3-
import * as ReactDOM from 'react-dom';
4-
import { useStableCallback } from '@base-ui/utils/useStableCallback';
53
import { usePopoverRootContext } from '../root/PopoverRootContext';
64
import { useButton } from '../../use-button/useButton';
75
import type { BaseUIComponentProps, NativeButtonProps } from '../../utils/types';
@@ -22,17 +20,9 @@ import { OPEN_DELAY } from '../utils/constants';
2220
import { PopoverHandle } from '../store/PopoverHandle';
2321
import { useBaseUiId } from '../../utils/useBaseUiId';
2422
import { FocusGuard } from '../../utils/FocusGuard';
25-
import {
26-
contains,
27-
type FocusableElement,
28-
getNextTabbable,
29-
getTabbableAfterElement,
30-
getTabbableBeforeElement,
31-
isOutsideEvent,
32-
} from '../../floating-ui-react/utils';
33-
import { createChangeEventDetails } from '../../utils/createBaseUIEventDetails';
3423
import { REASONS } from '../../utils/reasons';
3524
import { useTriggerDataForwarding } from '../../utils/popups';
25+
import { useTriggerFocusGuards } from '../../utils/popups/useTriggerFocusGuards';
3626

3727
/**
3828
* A button that opens the popover.
@@ -150,57 +140,8 @@ export const PopoverTrigger = React.forwardRef(function PopoverTrigger(
150140
stateAttributesMapping,
151141
});
152142

153-
const preFocusGuardRef = React.useRef<HTMLElement>(null);
154-
155-
const handlePreFocusGuardFocus = useStableCallback((event: React.FocusEvent) => {
156-
ReactDOM.flushSync(() => {
157-
store.setOpen(
158-
false,
159-
createChangeEventDetails(
160-
REASONS.focusOut,
161-
event.nativeEvent,
162-
event.currentTarget as HTMLElement,
163-
),
164-
);
165-
});
166-
167-
const previousTabbable: FocusableElement | null = getTabbableBeforeElement(
168-
preFocusGuardRef.current,
169-
);
170-
previousTabbable?.focus();
171-
});
172-
173-
const handleFocusTargetFocus = useStableCallback((event: React.FocusEvent) => {
174-
const positionerElement = store.select('positionerElement');
175-
if (positionerElement && isOutsideEvent(event, positionerElement)) {
176-
store.context.beforeContentFocusGuardRef.current?.focus();
177-
} else {
178-
ReactDOM.flushSync(() => {
179-
store.setOpen(
180-
false,
181-
createChangeEventDetails(
182-
REASONS.focusOut,
183-
event.nativeEvent,
184-
event.currentTarget as HTMLElement,
185-
),
186-
);
187-
});
188-
189-
let nextTabbable = getTabbableAfterElement(
190-
store.context.triggerFocusTargetRef.current || triggerElementRef.current,
191-
);
192-
193-
while (nextTabbable !== null && contains(positionerElement, nextTabbable)) {
194-
const prevTabbable = nextTabbable;
195-
nextTabbable = getNextTabbable(nextTabbable);
196-
if (nextTabbable === prevTabbable) {
197-
break;
198-
}
199-
}
200-
201-
nextTabbable?.focus();
202-
}
203-
});
143+
const { preFocusGuardRef, handlePreFocusGuardFocus, handleFocusTargetFocus } =
144+
useTriggerFocusGuards(store, triggerElementRef);
204145

205146
// A fragment with key is required to ensure that the `element` is mounted to the same DOM node
206147
// regardless of whether the focus guards are rendered or not.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
'use client';
2+
import * as React from 'react';
3+
import * as ReactDOM from 'react-dom';
4+
import { useStableCallback } from '@base-ui/utils/useStableCallback';
5+
import {
6+
contains,
7+
type FocusableElement,
8+
getNextTabbable,
9+
getTabbableAfterElement,
10+
getTabbableBeforeElement,
11+
isOutsideEvent,
12+
} from '../../floating-ui-react/utils';
13+
import {
14+
type BaseUIChangeEventDetails,
15+
createChangeEventDetails,
16+
} from '../createBaseUIEventDetails';
17+
import { REASONS } from '../reasons';
18+
19+
/**
20+
* Minimal store interface required by the focus guard hook.
21+
* Both PopoverStore and MenuStore satisfy this interface.
22+
*/
23+
interface TriggerFocusGuardStore {
24+
setOpen(open: boolean, eventDetails: BaseUIChangeEventDetails<typeof REASONS.focusOut>): void;
25+
select(key: 'positionerElement'): HTMLElement | null;
26+
context: {
27+
readonly beforeContentFocusGuardRef: React.RefObject<HTMLElement | null>;
28+
readonly triggerFocusTargetRef: React.RefObject<HTMLElement | null>;
29+
};
30+
}
31+
32+
/**
33+
* Provides focus guard handlers for popup triggers (Popover, Menu).
34+
*
35+
* When the popup is open, invisible focus guard elements are placed before and after
36+
* the trigger. These handlers close the popup and move focus to the appropriate
37+
* tabbable element when the guards receive focus (i.e. when the user tabs out).
38+
*/
39+
export function useTriggerFocusGuards(
40+
store: TriggerFocusGuardStore,
41+
triggerElementRef: React.RefObject<HTMLElement | null>,
42+
) {
43+
const preFocusGuardRef = React.useRef<HTMLElement>(null);
44+
45+
const handlePreFocusGuardFocus = useStableCallback((event: React.FocusEvent) => {
46+
ReactDOM.flushSync(() => {
47+
store.setOpen(
48+
false,
49+
createChangeEventDetails(
50+
REASONS.focusOut,
51+
event.nativeEvent,
52+
event.currentTarget as HTMLElement,
53+
),
54+
);
55+
});
56+
57+
const previousTabbable: FocusableElement | null = getTabbableBeforeElement(
58+
preFocusGuardRef.current,
59+
);
60+
previousTabbable?.focus();
61+
});
62+
63+
const handleFocusTargetFocus = useStableCallback((event: React.FocusEvent) => {
64+
const positionerElement = store.select('positionerElement');
65+
if (positionerElement && isOutsideEvent(event, positionerElement)) {
66+
store.context.beforeContentFocusGuardRef.current?.focus();
67+
} else {
68+
ReactDOM.flushSync(() => {
69+
store.setOpen(
70+
false,
71+
createChangeEventDetails(
72+
REASONS.focusOut,
73+
event.nativeEvent,
74+
event.currentTarget as HTMLElement,
75+
),
76+
);
77+
});
78+
79+
let nextTabbable = getTabbableAfterElement(
80+
store.context.triggerFocusTargetRef.current || triggerElementRef.current,
81+
);
82+
83+
while (nextTabbable !== null && contains(positionerElement, nextTabbable)) {
84+
const prevTabbable = nextTabbable;
85+
nextTabbable = getNextTabbable(nextTabbable);
86+
if (nextTabbable === prevTabbable) {
87+
break;
88+
}
89+
}
90+
91+
nextTabbable?.focus();
92+
}
93+
});
94+
95+
return { preFocusGuardRef, handlePreFocusGuardFocus, handleFocusTargetFocus };
96+
}

0 commit comments

Comments
 (0)