diff --git a/src/components/ContextualMenu/ContextualMenu.test.tsx b/src/components/ContextualMenu/ContextualMenu.test.tsx index 5c2f991b..d113903a 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(() => { @@ -301,4 +301,94 @@ 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(); + }); + }); }); diff --git a/src/components/ContextualMenu/ContextualMenu.tsx b/src/components/ContextualMenu/ContextualMenu.tsx index 3aff7f2f..97e00d07 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,116 @@ 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; + } + }; + + 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(); }, 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 +403,7 @@ const ContextualMenu = ({ toggleNode = (