diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index 259130cb..a3b7334b 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -302,6 +302,11 @@ export function useAccessibility( const parentMenuElement = key2element.get(parentKey); + // A submenu consumes Escape; the surrounding popup handles root Escape. + if (which === ESC && parentMenuElement) { + e.stopPropagation(); + } + // Focus menu item triggerAccessibilityOpen(parentKey, false); tryFocus(parentMenuElement); diff --git a/tests/Keyboard.spec.tsx b/tests/Keyboard.spec.tsx index e1b89ef4..5cb27720 100644 --- a/tests/Keyboard.spec.tsx +++ b/tests/Keyboard.spec.tsx @@ -44,6 +44,47 @@ describe('Menu.Keyboard', () => { }); } + it.each([1, 2])('consumes submenu Escape at depth %s but lets root Escape bubble', depth => { + const onOpenChange = jest.fn(); + const onWindowKeyDown = jest.fn(); + window.addEventListener('keydown', onWindowKeyDown); + const { container, getByText, unmount } = render( + + + {depth === 2 ? ( + + Child + + ) : ( + Child + )} + + , + ); + for (let i = 0; i < 3; i += 1) { + act(() => jest.runAllTimers()); + } + try { + const child = getByText('Child').closest('[role="menuitem"]'); + act(() => child.focus()); + fireEvent.keyDown(child, { which: KeyCode.ESC, keyCode: KeyCode.ESC }); + act(() => jest.runAllTimers()); + expect(onOpenChange).toHaveBeenLastCalledWith(depth === 2 ? ['sub'] : []); + expect(document.activeElement).toBe( + getByText(depth === 2 ? 'Inner' : 'Submenu').closest('[role="menuitem"]'), + ); + expect(onWindowKeyDown).not.toHaveBeenCalled(); + + const rootItem = getByText('Submenu').closest('[role="menuitem"]'); + act(() => rootItem.focus()); + fireEvent.keyDown(rootItem, { which: KeyCode.ESC, keyCode: KeyCode.ESC }); + expect(onWindowKeyDown).toHaveBeenCalledTimes(1); + } finally { + window.removeEventListener('keydown', onWindowKeyDown); + unmount(); + } + }); + it('no data-menu-id by init', () => { const { container } = render(