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" ? (