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 = (
+
+ );
+ const { container } = render(
+ {overlay} : overlay}
+ >
+
+ ,
+ );
+ const trigger = container.querySelector('.my-button');
- try {
- const overlay = (
-
- );
- 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();
+ }
+});