Skip to content

Commit 420d0c2

Browse files
authored
fix(menu): prevent submenu blur from closing parent menu (#22186)
* fix(menu): prevent submenu blur from closing parent menu * fix: prevent close on return to parent * chore: cleanup * fix: focus conflicts * docs: add comment * test: codecov
1 parent d249fb7 commit 420d0c2

2 files changed

Lines changed: 83 additions & 1 deletion

File tree

packages/react/src/components/Menu/Menu-test.js

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,59 @@ describe('Menu', () => {
229229
expect(menus[0]).toHaveClass('cds--menu--open');
230230
expect(menus[1]).toHaveClass('cds--menu--open');
231231
});
232+
233+
it('should close submenu and keep root menu open when submenu is blurred', async () => {
234+
const menus = screen.getAllByRole('menu');
235+
236+
const submenu = screen.getByRole('menuitem', {
237+
name: 'Submenu Submenu',
238+
});
239+
240+
// hover over submenu to open it
241+
await act(() => {
242+
fireEvent.mouseEnter(submenu);
243+
jest.runOnlyPendingTimers();
244+
});
245+
expect(menus[0]).toHaveClass('cds--menu--open');
246+
expect(menus[1]).toHaveClass('cds--menu--open');
247+
248+
// blur the submenu to close it, root menu should stay open
249+
await act(() => {
250+
fireEvent.mouseLeave(submenu);
251+
jest.runOnlyPendingTimers();
252+
});
253+
254+
expect(menus[0]).toHaveClass('cds--menu--open');
255+
expect(menus[1]).not.toHaveClass('cds--menu--open');
256+
});
257+
258+
it('should not close submenu when hovering back to parent menu item', async () => {
259+
const menus = screen.getAllByRole('menu');
260+
261+
const submenu = screen.getByRole('menuitem', {
262+
name: 'Submenu Submenu',
263+
});
264+
265+
// hover over submenu to open it
266+
await act(() => {
267+
fireEvent.mouseEnter(submenu);
268+
jest.runOnlyPendingTimers();
269+
});
270+
expect(menus[0]).toHaveClass('cds--menu--open');
271+
expect(menus[1]).toHaveClass('cds--menu--open');
272+
273+
// simulate mouse leaving child and entering parent
274+
const childMenuItem = screen.getByRole('menuitem', { name: 'Item' });
275+
await act(() => {
276+
fireEvent.mouseLeave(childMenuItem, {
277+
relatedTarget: submenu,
278+
});
279+
jest.runOnlyPendingTimers();
280+
});
281+
282+
expect(menus[0]).toHaveClass('cds--menu--open');
283+
expect(menus[1]).toHaveClass('cds--menu--open');
284+
});
232285
});
233286
});
234287

packages/react/src/components/Menu/MenuItem.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,36 @@ export const MenuItem = forwardRef<HTMLLIElement, MenuItemProps>(
117117
context: floatingContext,
118118
} = useFloating({
119119
open: submenuOpen,
120-
onOpenChange: setSubmenuOpen,
120+
onOpenChange: (open, event) => {
121+
if (open) {
122+
setSubmenuOpen(true);
123+
} else {
124+
const relatedTarget =
125+
event && 'relatedTarget' in event ? event.relatedTarget : null;
126+
127+
// Do not close submenu if hovering back to its parent
128+
if (
129+
relatedTarget instanceof Node &&
130+
menuItem.current?.contains(relatedTarget)
131+
) {
132+
return;
133+
}
134+
135+
setSubmenuOpen(false);
136+
137+
// do not focus parent menu if moving to another submenu,
138+
// focus should instead move to that submenu
139+
const movingToSubmenu =
140+
relatedTarget instanceof HTMLElement &&
141+
relatedTarget
142+
.closest('[role="menuitem"]')
143+
?.querySelector('[role="menu"]');
144+
145+
if (!movingToSubmenu) {
146+
menuItem.current?.focus();
147+
}
148+
}
149+
},
121150
placement: rtl ? 'left-start' : 'right-start',
122151
whileElementsMounted: autoUpdate,
123152
middleware: [offset({ mainAxis: -6, crossAxis: -6 })],

0 commit comments

Comments
 (0)