diff --git a/src/components/ContextualMenu/ContextualMenu.test.tsx b/src/components/ContextualMenu/ContextualMenu.test.tsx index 5c2f991b..47ef7043 100644 --- a/src/components/ContextualMenu/ContextualMenu.test.tsx +++ b/src/components/ContextualMenu/ContextualMenu.test.tsx @@ -301,4 +301,16 @@ describe("ContextualMenu ", () => { await userEvent.click(screen.getByTestId("child-span")); expect(screen.getByLabelText(DropdownLabel.Dropdown)).toBeInTheDocument(); }); + it("closes the menu on Escape key", async () => { + render(); + + await userEvent.click(screen.getByRole("button", { name: "Toggle" })); + expect(screen.getByLabelText(DropdownLabel.Dropdown)).toBeInTheDocument(); + + await userEvent.keyboard("{Escape}"); + + expect( + screen.queryByLabelText(DropdownLabel.Dropdown), + ).not.toBeInTheDocument(); + }); }); diff --git a/src/components/ContextualMenu/ContextualMenu.tsx b/src/components/ContextualMenu/ContextualMenu.tsx index 3aff7f2f..43ef7fa0 100644 --- a/src/components/ContextualMenu/ContextualMenu.tsx +++ b/src/components/ContextualMenu/ContextualMenu.tsx @@ -215,7 +215,25 @@ const ContextualMenu = ({ }, programmaticallyOpen: true, }); + // Listens for Escape key while the menu is open. + // Closes the portal when Escape is pressed. + // Added in capture phase so it still works even if + // a nested component stops event propagation. + useEffect(() => { + if (!isOpen) return undefined; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + closePortal?.(); + } + }; + document.addEventListener("keydown", onKeyDown, true); + return () => { + document.removeEventListener("keydown", onKeyDown, true); + }; + }, [isOpen, closePortal]); const previousVisible = usePrevious(visible); const labelNode = toggleLabel && typeof toggleLabel === "string" ? (