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]);