From d0c0a284b8c913e371bb278d10a97a5074482e68 Mon Sep 17 00:00:00 2001 From: Guillaume Flambard Date: Wed, 2 Sep 2026 22:12:38 +0200 Subject: [PATCH] fix(contextual-menu): route Tab into the menu when opened with the mouse The dropdown renders through a portal, so when the menu was opened with the mouse the menu items sat after every other page element in tab order: pressing Tab walked the page instead of entering the menu. Keyboard-opened menus already moved focus to the first item. When the menu is open and Tab is pressed while focus is still on the toggle, focus now moves to the first menu item. Tab from anywhere else is untouched, mouse behaviour is unchanged (no autofocus on click), and the existing wrap-around behaviour is preserved. Signed-off-by: Guillaume Flambard --- .../ContextualMenu/ContextualMenu.test.tsx | 59 +++++++++++++++++++ .../ContextualMenu/ContextualMenu.tsx | 22 +++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/components/ContextualMenu/ContextualMenu.test.tsx b/src/components/ContextualMenu/ContextualMenu.test.tsx index 5fd5122f7..4a7f5b2a0 100644 --- a/src/components/ContextualMenu/ContextualMenu.test.tsx +++ b/src/components/ContextualMenu/ContextualMenu.test.tsx @@ -371,6 +371,65 @@ describe("ContextualMenu ", () => { expect(screen.getByTestId("item-0")).not.toHaveFocus(); }); + it("routes Tab into the menu when it was opened by a mouse", async () => { + // A focusable element rendered after the menu: without routing, Tab + // walks the page instead of entering the open menu. + const links = [0, 1].map((i) => ({ + "data-testid": `item-${i}`, + children: `Item ${i}`, + })); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + <> + toggle} + /> + + , + ); + const toggle = screen.getByRole("button", { name: /toggle/i }); + + await user.click(toggle); + jest.runOnlyPendingTimers(); + + expect(screen.getByTestId("item-0")).not.toHaveFocus(); + + await user.tab(); + expect(screen.getByTestId("item-0")).toHaveFocus(); + expect(screen.getByTestId("after")).not.toHaveFocus(); + }); + + it("leaves Tab alone when focus is not on the toggle", async () => { + const links = [0, 1].map((i) => ({ + "data-testid": `item-${i}`, + children: `Item ${i}`, + })); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + render( + <> + + toggle} + /> + , + ); + await user.click(screen.getByRole("button", { name: /toggle/i })); + jest.runOnlyPendingTimers(); + + // Move focus without clicking, so the menu does not close. + screen.getByTestId("outside").focus(); + await user.tab(); + expect(screen.getByTestId("item-0")).not.toHaveFocus(); + }); + it("cleans up focus event listeners when unmounted", async () => { const { user, toggle, unmount } = setup(); diff --git a/src/components/ContextualMenu/ContextualMenu.tsx b/src/components/ContextualMenu/ContextualMenu.tsx index 22b08de21..70eac52da 100644 --- a/src/components/ContextualMenu/ContextualMenu.tsx +++ b/src/components/ContextualMenu/ContextualMenu.tsx @@ -307,7 +307,7 @@ const ContextualMenu = ({ }); /** - * Trap focus within the dropdown + * Trap focus within the dropdown and route keyboard focus into it. */ useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -325,13 +325,25 @@ const ContextualMenu = ({ // Shift+Tab on the first item: wrap back to the last focusable item e.preventDefault(); last.focus(); + } else if ( + !e.shiftKey && + active === + wrapper.current?.querySelector( + ".p-contextual-menu__toggle", + ) + ) { + // The menu is open but focus is still on the toggle, e.g. the menu + // was opened with the mouse: Tab must enter the menu instead of + // walking the rest of the page. + e.preventDefault(); + first.focus(); } }; - const dropdown = getDropdown(); - if (!dropdown) return undefined; - dropdown.addEventListener("keydown", handleKeyDown); + // The toggle lives outside the dropdown element, so the listener is + // document level to catch both the wrap cases and the toggle case. + document.addEventListener("keydown", handleKeyDown); return () => { - dropdown.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("keydown", handleKeyDown); }; }, [getDropdown, getFocusableDropdownItems, isOpen]);