From a7c05d2ee40971ecc0002d43ca2ffe3e04e2b93b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=B3=E5=BB=B7=E5=AE=89?= <73953029+nrps9909@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:54:57 +0800 Subject: [PATCH] fix: defer nested menu Escape to its owner --- src/hooks/useAccessibility.ts | 18 +++++++++++-- tests/useAccessibility.test.tsx | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index e9571ee..0ff3a92 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -52,11 +52,25 @@ export default function useAccessibility({ return focused; }; - const handleKeyDown = (event) => { + const handleKeyDown = (event: KeyboardEvent) => { switch (event.keyCode) { - case ESC: + case ESC: { + // Let a nested menu handle Escape before closing the outer popup. + const overlay = overlayRef?.current; + const overlayElement = overlay?.list || overlay; + const rootMenu = overlayElement?.matches?.('[role="menu"]') + ? overlayElement + : overlayElement?.querySelector?.('[role="menu"]'); + const targetMenu = + event.target instanceof Element + ? event.target.closest('[role="menu"]') + : null; + if (rootMenu && targetMenu && targetMenu !== rootMenu) { + break; + } handleCloseMenuAndReturnFocus(); break; + } case TAB: { let focusResult: boolean = false; if (!focusMenuRef.current) { diff --git a/tests/useAccessibility.test.tsx b/tests/useAccessibility.test.tsx index 931d508..aeb3d70 100644 --- a/tests/useAccessibility.test.tsx +++ b/tests/useAccessibility.test.tsx @@ -28,3 +28,51 @@ it('closes without consuming Tab when the overlay ref is omitted', () => { trigger.remove(); } }); + +it.each([false, true])( + 'leaves a portaled nested menu Escape to its owner (wrapped: %s)', + (wrapped) => { + const trigger = document.createElement('button'); + const wrapper = document.createElement('div'); + const root = document.createElement('ul'); + const submenu = document.createElement('ul'); + const child = document.createElement('li'); + root.setAttribute('role', 'menu'); + submenu.setAttribute('role', 'menu'); + child.tabIndex = -1; + submenu.appendChild(child); + wrapper.appendChild(root); + document.body.append(trigger, wrapper, submenu); + const onOpenChange = jest.fn(); + const { unmount } = renderHook(() => + useAccessibility({ + open: true, + triggerRef: { current: trigger }, + onOpenChange, + overlayRef: { current: wrapped ? wrapper : { list: root } }, + }), + ); + try { + child.focus(); + act(() => + child.dispatchEvent( + new KeyboardEvent('keydown', { keyCode: 27, bubbles: true }), + ), + ); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(child); + act(() => + root.dispatchEvent( + new KeyboardEvent('keydown', { keyCode: 27, bubbles: true }), + ), + ); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(document.activeElement).toBe(trigger); + } finally { + unmount(); + trigger.remove(); + wrapper.remove(); + submenu.remove(); + } + }, +);