Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 92 additions & 2 deletions src/components/ContextualMenu/ContextualMenu.test.tsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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(
<ContextualMenu
links={links}
dropdownProps={{ "data-testid": "dropdown" }}
position="right"
toggleLabel={<span>toggle</span>}
{...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();
});
});
});
131 changes: 119 additions & 12 deletions src/components/ContextualMenu/ContextualMenu.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -73,6 +79,12 @@ export type BaseProps<L> = 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.
*/
Expand Down Expand Up @@ -179,12 +191,14 @@ const ContextualMenu = <L,>({
toggleLabelFirst = true,
toggleProps,
visible = false,
focusFirstItemOnOpen = true,
...wrapperProps
}: Props<L>): React.JSX.Element => {
const id = useId();
const dropdownId = useId();
const wrapper = useRef<HTMLSpanElement | null>(null);
const [positionCoords, setPositionCoords] = useState<DOMRect>();
const [adjustedPosition, setAdjustedPosition] = useState(position);
const focusAnimationFrameId = useRef<number | null>(null);

useEffect(() => {
setAdjustedPosition(position);
Expand All @@ -199,23 +213,116 @@ const ContextualMenu = <L,>({
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]);
Comment on lines +220 to +227
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might be able to get away without using an ID here if we use the dropdownProps.ref directly. I haven't tested this, but here's an example from Gemini:

import { useRef, useEffect } from 'react';

// No forwardRef wrapper needed in React 19!
function MyInput({ ref: externalRef, ...props }) {
  const internalRef = useRef(null);

  // Callback ref to sync both internal and external refs
  const mergedRef = (node) => {
    // 1. Assign to our internal ref for logic within this component
    internalRef.current = node;

    // 2. Assign to the ref passed by the parent
    if (typeof externalRef === 'function') {
      externalRef(node);
    } else if (externalRef) {
      externalRef.current = node;
    }
  };

  useEffect(() => {
    // Now you can use the node internally
    if (internalRef.current) {
      internalRef.current.style.backgroundColor = 'lightyellow';
    }
  }, []);

  return <input {...props} ref={mergedRef} />;
}

It's a bit messy because the dropdownProps.ref could be a callback or an object, but it seems like it would work if you want to avoid an ID here.

If we do this, we might also be able to consolidate a lot of these callbacks and effects into a single callback ref.


/**
* 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<HTMLElement>(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<HTMLElement>` 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" ? (
Expand Down Expand Up @@ -296,7 +403,7 @@ const ContextualMenu = <L,>({
toggleNode = (
<Button
appearance={toggleAppearance}
aria-controls={id}
aria-controls={dropdownId}
aria-expanded={isOpen ? "true" : "false"}
aria-label={toggleLabel ? null : Label.Toggle}
aria-pressed={isOpen ? "true" : "false"}
Expand Down Expand Up @@ -346,7 +453,7 @@ const ContextualMenu = <L,>({
constrainPanelWidth={constrainPanelWidth}
dropdownClassName={dropdownClassName}
dropdownContent={children}
id={id}
id={dropdownId}
isOpen={isOpen}
links={links}
position={position}
Expand Down
1 change: 1 addition & 0 deletions src/components/CustomSelect/CustomSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ const CustomSelect = ({
.join(" ")}
aria-errormessage={hasError ? validationId : undefined}
aria-invalid={hasError}
focusFirstItemOnOpen={false}
toggleClassName={classNames(
"p-custom-select__toggle",
"p-form-validation__input",
Expand Down
Loading