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 1/2] 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(); + } + }, +); From b32c80c724e5cabd39eb3d9be64b2f42933f1c67 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: Wed, 4 Nov 2026 10:17:30 +0800 Subject: [PATCH 2/2] fix: retain outer Escape behavior for inline submenus --- src/hooks/useAccessibility.ts | 15 +++++- tests/useAccessibility.test.tsx | 96 +++++++++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index 0ff3a92..63964bc 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -55,7 +55,8 @@ export default function useAccessibility({ const handleKeyDown = (event: KeyboardEvent) => { switch (event.keyCode) { case ESC: { - // Let a nested menu handle Escape before closing the outer popup. + // Portaled popup submenus own Escape. Inline mode submenus retain + // outer close/focus, even when popup containers are customized. const overlay = overlayRef?.current; const overlayElement = overlay?.list || overlay; const rootMenu = overlayElement?.matches?.('[role="menu"]') @@ -65,7 +66,17 @@ export default function useAccessibility({ event.target instanceof Element ? event.target.closest('[role="menu"]') : null; - if (rootMenu && targetMenu && targetMenu !== rootMenu) { + const inlineSubmenu = + rootMenu?.contains(targetMenu) && + Array.from(targetMenu?.classList || []).some((className) => + className.endsWith('-inline'), + ); + if ( + rootMenu && + targetMenu && + targetMenu !== rootMenu && + !inlineSubmenu + ) { break; } handleCloseMenuAndReturnFocus(); diff --git a/tests/useAccessibility.test.tsx b/tests/useAccessibility.test.tsx index aeb3d70..37e8628 100644 --- a/tests/useAccessibility.test.tsx +++ b/tests/useAccessibility.test.tsx @@ -1,4 +1,6 @@ -import { act, renderHook } from '@testing-library/react'; +import * as React from 'react'; +import Menu from '@rc-component/menu'; +import { act, fireEvent, render, renderHook } from '@testing-library/react'; import useAccessibility from '../src/hooks/useAccessibility'; it('closes without consuming Tab when the overlay ref is omitted', () => { @@ -29,9 +31,14 @@ it('closes without consuming Tab when the overlay ref is omitted', () => { } }); -it.each([false, true])( - 'leaves a portaled nested menu Escape to its owner (wrapped: %s)', - (wrapped) => { +it.each([ + [false, false], + [true, false], + [false, true], + [true, true], +])( + 'leaves popup nested menu Escape to its owner (wrapped: %s, contained: %s)', + (wrapped, contained) => { const trigger = document.createElement('button'); const wrapper = document.createElement('div'); const root = document.createElement('ul'); @@ -43,6 +50,7 @@ it.each([false, true])( submenu.appendChild(child); wrapper.appendChild(root); document.body.append(trigger, wrapper, submenu); + if (contained) root.appendChild(submenu); const onOpenChange = jest.fn(); const { unmount } = renderHook(() => useAccessibility({ @@ -76,3 +84,83 @@ it.each([false, true])( } }, ); + +it.each([false, true])( + 'closes for an inline nested menu (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'); + submenu.className = 'custom-menu-sub custom-menu-inline'; + child.tabIndex = -1; + submenu.appendChild(child); + root.appendChild(submenu); + wrapper.appendChild(root); + document.body.append(trigger, wrapper); + 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).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(document.activeElement).toBe(trigger); + } finally { + unmount(); + trigger.remove(); + wrapper.remove(); + } + }, +); + +it('closes and restores focus from an actual rc-menu inline child', () => { + const onOpenChange = jest.fn(); + const Fixture = () => { + const triggerRef = React.useRef(null); + const overlayRef = React.useRef(null); + useAccessibility({ open: true, triggerRef, overlayRef, onOpenChange }); + return ( + <> + + + + ); + }; + const { getByText, unmount } = render(); + try { + const child = getByText('Child').closest('li'); + child.focus(); + fireEvent.keyDown(child, { key: 'Escape', keyCode: 27, which: 27 }); + expect(onOpenChange).toHaveBeenCalledTimes(1); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(getByText('Trigger')).toHaveFocus(); + } finally { + unmount(); + } +});