From 961baf924f9687f70260cb1c6a0255dbbb9eb65a 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, 2 Sep 2026 13:59:35 +0800 Subject: [PATCH 1/4] fix: focus wrapped dropdown menus --- src/hooks/useAccessibility.ts | 20 +++++++++++++++----- tests/basic.test.tsx | 9 ++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index a946e39..84b562c 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -28,12 +28,22 @@ export default function useAccessibility({ }; const focusMenu = (options?: FocusOptions) => { - if (overlayRef.current?.focus) { - overlayRef.current.focus(options); - focusMenuRef.current = true; - return true; + const overlay = overlayRef.current; + if (!overlay?.focus) { + return false; } - return false; + + const activeElement = document.activeElement; + overlay.focus(options); + if (document.activeElement === activeElement) { + const focusTarget = (overlay.querySelector?.('[role="menu"]') ?? + overlay.querySelector?.('[tabindex]')) as HTMLElement | null; + focusTarget?.focus(options); + } + + const focused = document.activeElement !== activeElement; + focusMenuRef.current = focused; + return focused; }; const handleKeyDown = (event) => { diff --git a/tests/basic.test.tsx b/tests/basic.test.tsx index ed7e5cf..937533f 100644 --- a/tests/basic.test.tsx +++ b/tests/basic.test.tsx @@ -492,9 +492,16 @@ describe('dropdown', () => { // Focus menu with Tab window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab + expect(document.activeElement).toHaveClass('rc-menu'); + fireEvent.keyDown(document.activeElement, { + key: 'ArrowDown', + keyCode: 40, + }); + await sleep(50); + expect(document.activeElement).toHaveTextContent('one'); // Close menu with Tab - window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab + fireEvent.keyDown(document.activeElement, { key: 'Tab', keyCode: 9 }); await sleep(200); expect(document.activeElement.className).toContain('my-button'); }); From a3263300d3ac45f7eaffcf16eaf3c5e4e9c2073d 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, 2 Sep 2026 15:36:29 +0800 Subject: [PATCH 2/4] fix: preserve optional overlay refs --- src/hooks/useAccessibility.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index 84b562c..0f6d26f 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -28,7 +28,7 @@ export default function useAccessibility({ }; const focusMenu = (options?: FocusOptions) => { - const overlay = overlayRef.current; + const overlay = overlayRef?.current; if (!overlay?.focus) { return false; } From e6c87ed9e7bdedc9eb27a098a203c7cf278b752f 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: Thu, 17 Sep 2026 16:19:39 +0800 Subject: [PATCH 3/4] test: preserve wrapped menu autoFocus scroll options --- tests/basic.test.tsx | 93 ++++++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 43 deletions(-) diff --git a/tests/basic.test.tsx b/tests/basic.test.tsx index 937533f..a5987d0 100644 --- a/tests/basic.test.tsx +++ b/tests/basic.test.tsx @@ -591,50 +591,57 @@ describe('dropdown', () => { jest.useRealTimers(); }); - it('should support autoFocus', async () => { - jest.useFakeTimers(); - const focusSpy = jest.spyOn(HTMLElement.prototype, 'focus'); + it.each(['direct', 'wrapped'])( + 'should support autoFocus for a %s menu', + async (mode) => { + jest.useFakeTimers(); + const focusSpy = jest.spyOn(HTMLElement.prototype, 'focus'); + + try { + const overlay = ( + + + one + + two + + ); + const { container } = render( + {overlay} : overlay} + > + + , + ); + const trigger = container.querySelector('.my-button'); - try { - const overlay = ( - - - one - - two - - ); - const { container } = render( - - - , - ); - const trigger = container.querySelector('.my-button'); - - // Open menu - fireEvent.click(trigger); - - await waitForTime(); - - expect( - container - .querySelector('.rc-dropdown') - .classList.contains('rc-dropdown-hidden'), - ).toBeFalsy(); - expect(document.activeElement.className).toContain('menu'); - expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }); - - // Close menu with Tab - window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab - - await waitForTime(); - - expect(document.activeElement.className).toContain('my-button'); - } finally { - focusSpy.mockRestore(); - jest.useRealTimers(); - } - }); + // Open menu + fireEvent.click(trigger); + + await waitForTime(); + + expect( + container + .querySelector('.rc-dropdown') + .classList.contains('rc-dropdown-hidden'), + ).toBeFalsy(); + expect(document.activeElement.className).toContain('menu'); + expect(focusSpy).toHaveBeenLastCalledWith({ preventScroll: true }); + + // Close menu with Tab + window.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 9 })); // Tab + + await waitForTime(); + + expect(document.activeElement.className).toContain('my-button'); + } finally { + focusSpy.mockRestore(); + jest.useRealTimers(); + } + }, + ); it('children cannot be given ref should not throw', () => { const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); From ed76a23a174be437a57d093d2228a7aa070661f1 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: Sun, 20 Sep 2026 14:52:49 +0800 Subject: [PATCH 4/4] fix: try tab target when wrapped menu cannot receive focus --- src/hooks/useAccessibility.ts | 12 ++++++--- tests/basic.test.tsx | 43 +++++++++++++++++++++++++++++++++ tests/useAccessibility.test.tsx | 30 +++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 tests/useAccessibility.test.tsx diff --git a/src/hooks/useAccessibility.ts b/src/hooks/useAccessibility.ts index 0f6d26f..e9571ee 100644 --- a/src/hooks/useAccessibility.ts +++ b/src/hooks/useAccessibility.ts @@ -36,9 +36,15 @@ export default function useAccessibility({ const activeElement = document.activeElement; overlay.focus(options); if (document.activeElement === activeElement) { - const focusTarget = (overlay.querySelector?.('[role="menu"]') ?? - overlay.querySelector?.('[tabindex]')) as HTMLElement | null; - focusTarget?.focus(options); + for (const selector of ['[role="menu"]', '[tabindex]']) { + const focusTarget = overlay.querySelector?.( + selector, + ) as HTMLElement | null; + focusTarget?.focus(options); + if (document.activeElement !== activeElement) { + break; + } + } } const focused = document.activeElement !== activeElement; diff --git a/tests/basic.test.tsx b/tests/basic.test.tsx index a5987d0..12c74da 100644 --- a/tests/basic.test.tsx +++ b/tests/basic.test.tsx @@ -506,6 +506,49 @@ describe('dropdown', () => { expect(document.activeElement.className).toContain('my-button'); }); + it.each(['missing', 'unfocusable'])( + 'focuses a tab target when the wrapped menu is %s', + async (menuState) => { + jest.useFakeTimers(); + try { + const { container, baseElement } = render( + + {menuState === 'unfocusable' &&
} + +
+ } + > + +
, + ); + const trigger = + container.querySelector('.my-button'); + trigger.focus(); + fireEvent.click(trigger); + await waitForTime(); + + const event = new KeyboardEvent('keydown', { + keyCode: 9, + cancelable: true, + }); + act(() => { + window.dispatchEvent(event); + }); + expect(document.activeElement).toBe( + baseElement.querySelector('.custom-target'), + ); + expect(event.defaultPrevented).toBe(true); + } finally { + jest.useRealTimers(); + } + }, + ); + it('support Menu expandIcon', async () => { const props = { overlay: ( diff --git a/tests/useAccessibility.test.tsx b/tests/useAccessibility.test.tsx new file mode 100644 index 0000000..931d508 --- /dev/null +++ b/tests/useAccessibility.test.tsx @@ -0,0 +1,30 @@ +import { act, renderHook } from '@testing-library/react'; +import useAccessibility from '../src/hooks/useAccessibility'; + +it('closes without consuming Tab when the overlay ref is omitted', () => { + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + const onOpenChange = jest.fn(); + const { unmount } = renderHook(() => + useAccessibility({ + open: true, + triggerRef: { current: trigger }, + onOpenChange, + }), + ); + try { + const event = new KeyboardEvent('keydown', { + keyCode: 9, + cancelable: true, + }); + act(() => { + window.dispatchEvent(event); + }); + expect(event.defaultPrevented).toBe(false); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(document.activeElement).toBe(trigger); + } finally { + unmount(); + trigger.remove(); + } +});