Skip to content

Commit 90bdb14

Browse files
authored
fix(container-menu): improve menu dropdown expansion and focus behavior on blur (#691)
1 parent 9475f21 commit 90bdb14

File tree

2 files changed

+114
-23
lines changed

2 files changed

+114
-23
lines changed

packages/menu/src/MenuContainer.spec.tsx

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -241,16 +241,45 @@ describe('MenuContainer', () => {
241241
expect(menu).not.toBeVisible();
242242
});
243243

244-
it('closes menu on blur', async () => {
244+
it('closes the menu on blur due to a body click, returns focus to the trigger the first time, and focuses the body on following clicks.', async () => {
245245
const { getByTestId } = render(<TestMenu items={ITEMS} />);
246246
const trigger = getByTestId('trigger');
247247
const menu = getByTestId('menu');
248248

249249
await waitFor(async () => {
250250
await user.click(trigger);
251+
});
252+
expect(menu).toBeVisible();
253+
254+
await waitFor(async () => {
255+
await user.click(document.body);
256+
});
257+
expect(trigger).toHaveFocus();
258+
expect(menu).not.toBeVisible();
259+
260+
await waitFor(async () => {
251261
await user.click(document.body);
252262
});
253263

264+
expect(document.body).toHaveFocus();
265+
});
266+
267+
it('closes menu on blur and moves focus to focusable element', async () => {
268+
const { getByTestId } = render(
269+
<>
270+
<TestMenu items={ITEMS} />
271+
<button data-test-id="focusable">Click me</button>
272+
</>
273+
);
274+
const trigger = getByTestId('trigger');
275+
const menu = getByTestId('menu');
276+
const button = getByTestId('focusable');
277+
278+
await waitFor(async () => {
279+
await user.click(trigger);
280+
await user.click(button);
281+
});
282+
expect(button).toHaveFocus();
254283
expect(menu).not.toBeVisible();
255284
});
256285

@@ -887,21 +916,33 @@ describe('MenuContainer', () => {
887916
expect(fruit1).toHaveAttribute('aria-checked', 'true');
888917
});
889918

890-
it('returns normal keyboard navigation after menu closes', async () => {
919+
it('returns focus to trigger before resuming normal tab navigation after menu closes', async () => {
891920
const { getByText, getByTestId } = render(
892921
<>
893922
<TestMenu items={ITEMS} />
894923
<button>focus me</button>
895924
</>
896925
);
897926
const trigger = getByTestId('trigger');
927+
const menu = getByTestId('menu');
898928
const nextFocusedElement = getByText('focus me');
899929

900930
trigger.focus();
901931

902932
await waitFor(async () => {
903-
await user.keyboard('{Enter}'); // select trigger
904-
await user.keyboard('{Enter}'); // select first item
933+
await user.keyboard('{ArrowDown}');
934+
});
935+
936+
expect(menu).toBeVisible();
937+
938+
await waitFor(async () => {
939+
await user.keyboard('{Tab}');
940+
});
941+
942+
expect(menu).not.toBeVisible();
943+
expect(trigger).toHaveFocus();
944+
945+
await waitFor(async () => {
905946
await user.keyboard('{Tab}');
906947
});
907948

packages/menu/src/useMenu.ts

Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -375,16 +375,52 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
375375
[closeMenu, returnFocusToTrigger]
376376
);
377377

378-
const handleMenuBlur = useCallback(
379-
(event: MouseEvent) => {
380-
const path = event.composedPath();
378+
/**
379+
* 1. Determine if the next element receiving focus is focusable
380+
* (event.relatedTarget is null when focus moves to non-focusable elements or body).
381+
*
382+
* 2. When an element loses focus (on blur), and focus moves to a non-focusable element
383+
* like <body>, `event.relatedTarget` should be `null`. However, due to a bug in jsdom
384+
* (prior to version 24.1.2), `relatedTarget` is incorrectly set to the `Document` node
385+
* (`nodeName === '#document'`).
386+
*
387+
* Currently, `jest-environment-jsdom` (v29.7.0) uses [email protected], which still has this issue.
388+
* Until Jest updates its jsdom dependency, this workaround ensures accurate
389+
* testing of focus behavior.
390+
*
391+
* @see https://github.com/jsdom/jsdom/pull/3767
392+
* @see https://github.com/jsdom/jsdom/releases/tag/24.1.2
393+
* @see https://github.com/jestjs/jest/blob/v29.7.0/packages/jest-environment-jsdom/package.json
394+
*
395+
* 3. Skip focus-return to trigger in these scenarios:
396+
* a. Focus is moving to another focusable element
397+
* b. Menu is closed and focus would naturally go to body
398+
*/
399+
const handleBlur = useCallback(
400+
(event: React.FocusEvent) => {
401+
const win = environment || window;
381402

382-
if (!path.includes(menuRef.current!) && !path.includes(triggerRef.current!)) {
383-
returnFocusToTrigger();
384-
closeMenu(StateChangeTypes.MenuBlur);
385-
}
403+
setTimeout(() => {
404+
// Timeout is required to ensure blur is handled after focus
405+
const activeElement = win.document.activeElement;
406+
const isMenuOrTriggerFocused =
407+
menuRef.current?.contains(activeElement) || triggerRef.current?.contains(activeElement);
408+
409+
if (!isMenuOrTriggerFocused) {
410+
const nextElementIsFocusable =
411+
!!event.relatedTarget /* [1] */ &&
412+
event.relatedTarget?.nodeName !== '#document'; /* [2] */
413+
414+
const shouldSkipFocusReturn =
415+
nextElementIsFocusable || (!controlledIsExpanded && !nextElementIsFocusable); /* [3] */
416+
417+
returnFocusToTrigger(shouldSkipFocusReturn);
418+
419+
closeMenu(StateChangeTypes.MenuBlur);
420+
}
421+
});
386422
},
387-
[closeMenu, menuRef, returnFocusToTrigger, triggerRef]
423+
[closeMenu, controlledIsExpanded, environment, menuRef, returnFocusToTrigger, triggerRef]
388424
);
389425

390426
const handleMenuMouseLeave = useCallback(() => {
@@ -602,18 +638,15 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
602638
const win = environment || window;
603639

604640
if (controlledIsExpanded) {
605-
win.document.addEventListener('click', handleMenuBlur, true);
606641
win.document.addEventListener('keydown', handleMenuKeyDown, true);
607642
} else if (!controlledIsExpanded) {
608-
win.document.removeEventListener('click', handleMenuBlur, true);
609643
win.document.removeEventListener('keydown', handleMenuKeyDown, true);
610644
}
611645

612646
return () => {
613-
win.document.removeEventListener('click', handleMenuBlur, true);
614647
win.document.removeEventListener('keydown', handleMenuKeyDown, true);
615648
};
616-
}, [controlledIsExpanded, handleMenuBlur, handleMenuKeyDown, environment]);
649+
}, [controlledIsExpanded, handleMenuKeyDown, environment]);
617650

618651
/**
619652
* When the menu is opened, this effect sets focus on the current menu item using `focusedValue`
@@ -690,7 +723,15 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
690723
*/
691724

692725
const getTriggerProps = useCallback<IUseMenuReturnValue['getTriggerProps']>(
693-
({ onClick, onKeyDown, type = 'button', role = 'button', disabled, ...other } = {}) => ({
726+
({
727+
onBlur,
728+
onClick,
729+
onKeyDown,
730+
type = 'button',
731+
role = 'button',
732+
disabled,
733+
...other
734+
} = {}) => ({
694735
...other,
695736
'data-garden-container-id': 'containers.menu.trigger',
696737
'data-garden-container-version': PACKAGE_VERSION,
@@ -702,14 +743,22 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
702743
tabIndex: disabled ? -1 : 0,
703744
type: type === null ? undefined : type,
704745
role: role === null ? undefined : role,
705-
onKeyDown: composeEventHandlers(onKeyDown, handleTriggerKeyDown),
706-
onClick: composeEventHandlers(onClick, handleTriggerClick)
746+
onBlur: composeEventHandlers(onBlur, handleBlur),
747+
onClick: composeEventHandlers(onClick, handleTriggerClick),
748+
onKeyDown: composeEventHandlers(onKeyDown, handleTriggerKeyDown)
707749
}),
708-
[triggerRef, controlledIsExpanded, handleTriggerClick, handleTriggerKeyDown, triggerId]
750+
[
751+
controlledIsExpanded,
752+
handleBlur,
753+
handleTriggerClick,
754+
handleTriggerKeyDown,
755+
triggerId,
756+
triggerRef
757+
]
709758
);
710759

711760
const getMenuProps = useCallback<IUseMenuReturnValue['getMenuProps']>(
712-
({ role = 'menu', onMouseLeave, ...other } = {}) => ({
761+
({ role = 'menu', onBlur, onMouseLeave, ...other } = {}) => ({
713762
...other,
714763
...getGroupProps({
715764
onMouseLeave: composeEventHandlers(onMouseLeave, handleMenuMouseLeave)
@@ -719,9 +768,10 @@ export const useMenu = <T extends HTMLElement = HTMLElement, M extends HTMLEleme
719768
'aria-labelledby': triggerId,
720769
tabIndex: -1,
721770
role: role === null ? undefined : role,
722-
ref: menuRef as any
771+
ref: menuRef as any,
772+
onBlur: composeEventHandlers(onBlur, handleBlur)
723773
}),
724-
[triggerId, menuRef, getGroupProps, handleMenuMouseLeave]
774+
[getGroupProps, handleBlur, handleMenuMouseLeave, menuRef, triggerId]
725775
);
726776

727777
const getSeparatorProps = useCallback<IUseMenuReturnValue['getSeparatorProps']>(

0 commit comments

Comments
 (0)