Skip to content

Commit a7c05d2

Browse files
committed
fix: defer nested menu Escape to its owner
1 parent b4ea62f commit a7c05d2

2 files changed

Lines changed: 64 additions & 2 deletions

File tree

‎src/hooks/useAccessibility.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,25 @@ export default function useAccessibility({
5252
return focused;
5353
};
5454

55-
const handleKeyDown = (event) => {
55+
const handleKeyDown = (event: KeyboardEvent) => {
5656
switch (event.keyCode) {
57-
case ESC:
57+
case ESC: {
58+
// Let a nested menu handle Escape before closing the outer popup.
59+
const overlay = overlayRef?.current;
60+
const overlayElement = overlay?.list || overlay;
61+
const rootMenu = overlayElement?.matches?.('[role="menu"]')
62+
? overlayElement
63+
: overlayElement?.querySelector?.('[role="menu"]');
64+
const targetMenu =
65+
event.target instanceof Element
66+
? event.target.closest('[role="menu"]')
67+
: null;
68+
if (rootMenu && targetMenu && targetMenu !== rootMenu) {
69+
break;
70+
}
5871
handleCloseMenuAndReturnFocus();
5972
break;
73+
}
6074
case TAB: {
6175
let focusResult: boolean = false;
6276
if (!focusMenuRef.current) {

‎tests/useAccessibility.test.tsx‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,51 @@ it('closes without consuming Tab when the overlay ref is omitted', () => {
2828
trigger.remove();
2929
}
3030
});
31+
32+
it.each([false, true])(
33+
'leaves a portaled nested menu Escape to its owner (wrapped: %s)',
34+
(wrapped) => {
35+
const trigger = document.createElement('button');
36+
const wrapper = document.createElement('div');
37+
const root = document.createElement('ul');
38+
const submenu = document.createElement('ul');
39+
const child = document.createElement('li');
40+
root.setAttribute('role', 'menu');
41+
submenu.setAttribute('role', 'menu');
42+
child.tabIndex = -1;
43+
submenu.appendChild(child);
44+
wrapper.appendChild(root);
45+
document.body.append(trigger, wrapper, submenu);
46+
const onOpenChange = jest.fn();
47+
const { unmount } = renderHook(() =>
48+
useAccessibility({
49+
open: true,
50+
triggerRef: { current: trigger },
51+
onOpenChange,
52+
overlayRef: { current: wrapped ? wrapper : { list: root } },
53+
}),
54+
);
55+
try {
56+
child.focus();
57+
act(() =>
58+
child.dispatchEvent(
59+
new KeyboardEvent('keydown', { keyCode: 27, bubbles: true }),
60+
),
61+
);
62+
expect(onOpenChange).not.toHaveBeenCalled();
63+
expect(document.activeElement).toBe(child);
64+
act(() =>
65+
root.dispatchEvent(
66+
new KeyboardEvent('keydown', { keyCode: 27, bubbles: true }),
67+
),
68+
);
69+
expect(onOpenChange).toHaveBeenCalledWith(false);
70+
expect(document.activeElement).toBe(trigger);
71+
} finally {
72+
unmount();
73+
trigger.remove();
74+
wrapper.remove();
75+
submenu.remove();
76+
}
77+
},
78+
);

0 commit comments

Comments
 (0)