diff --git a/Dockerfile b/Dockerfile
index fca915824..f77d8e3bd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,7 +2,7 @@
# Build stage: Install yarn dependencies
# ===
-FROM node:23 AS yarn-dependencies
+FROM node:24 AS yarn-dependencies
WORKDIR /srv
ADD package.json .
ADD yarn.lock .
diff --git a/src/components/ContextualMenu/ContextualMenu.test.tsx b/src/components/ContextualMenu/ContextualMenu.test.tsx
index 5c2f991ba..5fd5122f7 100644
--- a/src/components/ContextualMenu/ContextualMenu.test.tsx
+++ b/src/components/ContextualMenu/ContextualMenu.test.tsx
@@ -1,10 +1,10 @@
import { render, screen, within } from "@testing-library/react";
import React from "react";
-import ContextualMenu, { Label } from "./ContextualMenu";
-import { Label as DropdownLabel } from "./ContextualMenuDropdown/ContextualMenuDropdown";
import userEvent from "@testing-library/user-event";
import Button from "../Button";
+import ContextualMenu, { Label } from "./ContextualMenu";
+import { Label as DropdownLabel } from "./ContextualMenuDropdown/ContextualMenuDropdown";
describe("ContextualMenu ", () => {
afterEach(() => {
@@ -137,7 +137,7 @@ describe("ContextualMenu ", () => {
it("can display links", () => {
render();
- expect(screen.getByRole("button", { name: "Link1" })).toBeInTheDocument();
+ expect(screen.getByRole("menuitem", { name: "Link1" })).toBeInTheDocument();
});
it("can display links in groups", () => {
@@ -147,7 +147,7 @@ describe("ContextualMenu ", () => {
) as HTMLElement;
expect(group).toBeInTheDocument();
expect(
- within(group).getByRole("button", { name: "Link1" }),
+ within(group).getByRole("menuitem", { name: "Link1" }),
).toBeInTheDocument();
});
@@ -163,12 +163,12 @@ describe("ContextualMenu ", () => {
) as HTMLElement;
expect(group).toBeInTheDocument();
expect(
- within(group).getByRole("button", { name: "Link1" }),
+ within(group).getByRole("menuitem", { name: "Link1" }),
).toBeInTheDocument();
expect(
- within(group).queryByRole("button", { name: "Link2" }),
+ within(group).queryByRole("menuitem", { name: "Link2" }),
).not.toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Link2" })).toBeInTheDocument();
+ expect(screen.getByRole("menuitem", { name: "Link2" })).toBeInTheDocument();
});
it("can supply content instead of links", () => {
@@ -194,7 +194,7 @@ describe("ContextualMenu ", () => {
await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
expect(screen.getByLabelText(DropdownLabel.Dropdown)).toBeInTheDocument();
// Click on an item:
- await userEvent.click(screen.getByRole("button", { name: "Link1" }));
+ await userEvent.click(screen.getByRole("menuitem", { name: "Link1" }));
expect(
screen.queryByLabelText(DropdownLabel.Dropdown),
).not.toBeInTheDocument();
@@ -301,4 +301,108 @@ describe("ContextualMenu ", () => {
await userEvent.click(screen.getByTestId("child-span"));
expect(screen.getByLabelText(DropdownLabel.Dropdown)).toBeInTheDocument();
});
+
+ describe("focus behavior", () => {
+ const setup = (props = {}) => {
+ const links = [0, 1, 2].map((i) => ({
+ "data-testid": `item-${i}`,
+ children: `Item ${i}`,
+ }));
+ const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ const utils = render(
+ toggle}
+ {...props}
+ />,
+ );
+ const toggle = screen.getByRole("button", { name: /toggle/i });
+ return { ...utils, user, toggle, links };
+ };
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ it("focuses the first item when menu opens", async () => {
+ const { user, toggle } = setup();
+
+ await user.tab();
+ expect(toggle).toHaveFocus();
+ await user.keyboard("{Enter}");
+
+ jest.runOnlyPendingTimers();
+ expect(screen.getByTestId("item-0")).toHaveFocus();
+ });
+
+ it("traps focus", async () => {
+ const { user, links } = setup();
+
+ await user.tab();
+ await user.keyboard("{Enter}");
+ jest.runOnlyPendingTimers();
+
+ const firstItem = screen.getByTestId("item-0");
+ const lastItem = screen.getByTestId(`item-${links.length - 1}`);
+
+ // Tab to the end
+ for (let i = 0; i < links.length - 1; i++) {
+ await user.keyboard("{Tab}");
+ }
+ expect(lastItem).toHaveFocus();
+
+ // Wrap to start
+ await user.keyboard("{Tab}");
+ expect(firstItem).toHaveFocus();
+
+ // Wrap backwards
+ await user.keyboard("{Shift>}{Tab}{/Shift}");
+ expect(lastItem).toHaveFocus();
+ });
+
+ it("does not autofocus when opened by a mouse", async () => {
+ const { user, toggle } = setup();
+
+ await user.click(toggle);
+ jest.runOnlyPendingTimers();
+
+ expect(screen.getByTestId("item-0")).not.toHaveFocus();
+ });
+
+ it("cleans up focus event listeners when unmounted", async () => {
+ const { user, toggle, unmount } = setup();
+
+ await user.click(toggle);
+ unmount();
+ jest.runOnlyPendingTimers();
+
+ expect(document.activeElement).not.toBe(toggle);
+ });
+
+ it("does not autofocus when focusFirstItemOnOpen is false", async () => {
+ const { user, toggle } = setup({ focusFirstItemOnOpen: false });
+
+ await user.tab();
+ await user.keyboard("{Enter}");
+
+ jest.runOnlyPendingTimers();
+ expect(toggle).toHaveFocus();
+ });
+
+ it("returns focus to the trigger when the menu is closed", async () => {
+ const { user, toggle } = setup();
+
+ await user.tab();
+ expect(toggle).toHaveFocus();
+ await user.keyboard("{Enter}");
+ jest.runOnlyPendingTimers();
+ expect(screen.getByTestId("item-0")).toHaveFocus();
+
+ // Close the menu
+ await user.keyboard("{Escape}");
+ expect(toggle).toHaveFocus();
+ });
+ });
});
diff --git a/src/components/ContextualMenu/ContextualMenu.tsx b/src/components/ContextualMenu/ContextualMenu.tsx
index 3aff7f2f3..22b08de21 100644
--- a/src/components/ContextualMenu/ContextualMenu.tsx
+++ b/src/components/ContextualMenu/ContextualMenu.tsx
@@ -1,19 +1,25 @@
import classNames from "classnames";
-import React, { useCallback, useEffect, useId, useRef, useState } from "react";
-import type { HTMLProps, ReactNode } from "react";
+import { usePortal } from "external";
import { useListener, usePrevious } from "hooks";
-import Button from "../Button";
-import type { ButtonProps } from "../Button";
-import ContextualMenuDropdown from "./ContextualMenuDropdown";
-import type { ContextualMenuDropdownProps } from "./ContextualMenuDropdown";
-import type { MenuLink, Position } from "./ContextualMenuDropdown";
+import type { HTMLProps, ReactNode } from "react";
+import React, { useCallback, useEffect, useId, useRef, useState } from "react";
import {
ClassName,
ExclusiveProps,
PropsWithSpread,
SubComponentProps,
} from "types";
-import { usePortal } from "external";
+import type { ButtonProps } from "../Button";
+import Button from "../Button";
+import type {
+ ContextualMenuDropdownProps,
+ MenuLink,
+ Position,
+} from "./ContextualMenuDropdown";
+import ContextualMenuDropdown from "./ContextualMenuDropdown";
+
+const focusableElementSelectors =
+ 'a[href]:not([tabindex="-1"]), button:not([disabled]):not([aria-disabled="true"]), textarea:not([disabled]):not([aria-disabled="true"]):not([tabindex="-1"]), input:not([disabled]):not([aria-disabled="true"]):not([tabindex="-1"]), select:not([disabled]):not([aria-disabled="true"]):not([tabindex="-1"]), area[href]:not([tabindex="-1"]), iframe:not([tabindex="-1"]), [tabindex]:not([tabindex="-1"]), [contentEditable=true]:not([tabindex="-1"])';
export enum Label {
Toggle = "Toggle menu",
@@ -73,6 +79,12 @@ export type BaseProps = PropsWithSpread<
* Whether the dropdown should scroll if it is too long to fit on the screen.
*/
scrollOverflow?: boolean;
+ /**
+ * Whether to focus the first interactive element within the menu when it opens.
+ * This defaults to true.
+ * In instances where the user needs to interact with some other element on opening the menu (like a text input), set this to false.
+ */
+ focusFirstItemOnOpen?: boolean;
/**
* Whether the menu should be visible.
*/
@@ -179,12 +191,14 @@ const ContextualMenu = ({
toggleLabelFirst = true,
toggleProps,
visible = false,
+ focusFirstItemOnOpen = true,
...wrapperProps
}: Props): React.JSX.Element => {
- const id = useId();
+ const dropdownId = useId();
const wrapper = useRef(null);
const [positionCoords, setPositionCoords] = useState();
const [adjustedPosition, setAdjustedPosition] = useState(position);
+ const focusAnimationFrameId = useRef(null);
useEffect(() => {
setAdjustedPosition(position);
@@ -199,23 +213,128 @@ const ContextualMenu = ({
setPositionCoords(parent.getBoundingClientRect());
}, [wrapper, positionNode]);
+ /**
+ * Gets the dropdopwn element (`ContextualMenuDropdown`).
+ * @returns The dropdown element or null if it does not exist.
+ */
+ const getDropdown = useCallback(() => {
+ if (typeof document === "undefined") return null;
+ /**
+ * This is Using `document` instead of refs because `dropdownProps` may include a ref,
+ * while `dropdownId` is unique and controlled by us.
+ */
+ return document.getElementById(dropdownId);
+ }, [dropdownId]);
+
+ /**
+ * Gets all focusable items in the dropdown element.
+ * @returns Array of focusable items in the dropdown element.
+ */
+ const getFocusableDropdownItems = useCallback(() => {
+ return Array.from(
+ getDropdown()?.querySelectorAll(focusableElementSelectors) ||
+ [],
+ );
+ }, [getDropdown]);
+
+ /**
+ * Focuses the first focusable item in the dropdown element.
+ * This is useful for keyboard users (who expect focus to move into the menu when it opens).
+ */
+ const focusFirstDropdownItem = useCallback(() => {
+ const focusableElements = getFocusableDropdownItems();
+ focusableElements[0]?.focus();
+ }, [getFocusableDropdownItems]);
+
+ /**
+ * Cleans up any pending dropdown focus animation frames.
+ */
+ const cleanupDropdownFocus = () => {
+ if (focusAnimationFrameId.current) {
+ cancelAnimationFrame(focusAnimationFrameId.current);
+ focusAnimationFrameId.current = null;
+ }
+ };
+
+ const returnFocusToTrigger = () => {
+ // Button element does not accept refs.
+ const trigger = wrapper.current?.querySelector(
+ ".p-contextual-menu__toggle",
+ );
+ // return focus to the trigger button when the menu closes
+ if (trigger) {
+ trigger.focus();
+ }
+ };
+
+ useEffect(() => {
+ return () => cleanupDropdownFocus();
+ }, []);
+
const { openPortal, closePortal, isOpen, Portal } = usePortal({
closeOnEsc,
closeOnOutsideClick,
isOpen: visible,
- onOpen: () => {
+ onOpen: (event) => {
// Call the toggle callback, if supplied.
onToggleMenu?.(true);
// When the menu opens then update the coordinates of the parent.
updatePositionCoords();
+
+ if (
+ focusFirstItemOnOpen &&
+ // Don't focus the item unless it was opened by a keyboard event
+ // This type silliness is because `detail` isn't on the type for `event.nativeEvent` passed from `usePortal`,
+ // as we are using `CustomEvent` which does not have `detail` defined.
+ event?.nativeEvent &&
+ "detail" in event.nativeEvent &&
+ event.nativeEvent.detail === 0
+ ) {
+ cleanupDropdownFocus();
+ // We need to wait a frame for any pending focus events to complete.
+ focusAnimationFrameId.current = requestAnimationFrame(() =>
+ focusFirstDropdownItem(),
+ );
+ }
},
onClose: () => {
// Call the toggle callback, if supplied.
onToggleMenu?.(false);
+ cleanupDropdownFocus();
+ returnFocusToTrigger();
},
programmaticallyOpen: true,
});
+ /**
+ * Trap focus within the dropdown
+ */
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== "Tab" || !isOpen) return;
+ const items = getFocusableDropdownItems();
+ if (items.length === 0) return;
+ const active = document.activeElement;
+ const first = items[0];
+ const last = items[items.length - 1];
+ if (!e.shiftKey && active === last) {
+ // Tab on the last item: wrap back to the first focusable item
+ e.preventDefault();
+ first.focus();
+ } else if (e.shiftKey && active === first) {
+ // Shift+Tab on the first item: wrap back to the last focusable item
+ e.preventDefault();
+ last.focus();
+ }
+ };
+ const dropdown = getDropdown();
+ if (!dropdown) return undefined;
+ dropdown.addEventListener("keydown", handleKeyDown);
+ return () => {
+ dropdown.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [getDropdown, getFocusableDropdownItems, isOpen]);
+
const previousVisible = usePrevious(visible);
const labelNode =
toggleLabel && typeof toggleLabel === "string" ? (
@@ -296,7 +415,7 @@ const ContextualMenu = ({
toggleNode = (