From 20eafa55b95f26caef5e749ada192556bb1bef96 Mon Sep 17 00:00:00 2001 From: Julie Muzina Date: Tue, 13 Jan 2026 18:44:57 -0500 Subject: [PATCH 1/5] fix(ContextualMenuDropdown): Focus first item on opening --- .../ContextualMenu/ContextualMenu.test.tsx | 20 +++--- .../ContextualMenu/ContextualMenu.tsx | 8 +++ .../ContextualMenuDropdown.test.tsx | 45 ++++++++++++++ .../ContextualMenuDropdown.tsx | 61 ++++++++++++++++++- .../ContextualMenuDropdown.test.tsx.snap | 1 + src/components/MultiSelect/MultiSelect.tsx | 1 + 6 files changed, 123 insertions(+), 13 deletions(-) diff --git a/src/components/ContextualMenu/ContextualMenu.test.tsx b/src/components/ContextualMenu/ContextualMenu.test.tsx index 5c2f991ba..ca9fccc9a 100644 --- a/src/components/ContextualMenu/ContextualMenu.test.tsx +++ b/src/components/ContextualMenu/ContextualMenu.test.tsx @@ -137,17 +137,15 @@ 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", () => { render(); - const group = document.querySelector( - ".p-contextual-menu__group", - ) as HTMLElement; + const group = screen.getByRole("group"); expect(group).toBeInTheDocument(); expect( - within(group).getByRole("button", { name: "Link1" }), + within(group).getByRole("menuitem", { name: "Link1" }), ).toBeInTheDocument(); }); @@ -158,17 +156,15 @@ describe("ContextualMenu ", () => { visible />, ); - const group = document.querySelector( - ".p-contextual-menu__group", - ) as HTMLElement; + const group = screen.getByRole("group") 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 +190,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(); diff --git a/src/components/ContextualMenu/ContextualMenu.tsx b/src/components/ContextualMenu/ContextualMenu.tsx index 3aff7f2f3..5b3c591b4 100644 --- a/src/components/ContextualMenu/ContextualMenu.tsx +++ b/src/components/ContextualMenu/ContextualMenu.tsx @@ -77,6 +77,12 @@ export type BaseProps = PropsWithSpread< * Whether the menu should be visible. */ visible?: 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; }, HTMLProps >; @@ -179,6 +185,7 @@ const ContextualMenu = ({ toggleLabelFirst = true, toggleProps, visible = false, + focusFirstItemOnOpen = true, ...wrapperProps }: Props): React.JSX.Element => { const id = useId(); @@ -355,6 +362,7 @@ const ContextualMenu = ({ positionNode={getPositionNode(wrapper.current)} scrollOverflow={scrollOverflow} setAdjustedPosition={setAdjustedPosition} + focusFirstItemOnOpen={focusFirstItemOnOpen} {...dropdownProps} /> diff --git a/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.test.tsx b/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.test.tsx index bdb70e3df..4f7619c2a 100644 --- a/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.test.tsx +++ b/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.test.tsx @@ -1,4 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import merge from "deepmerge"; import React from "react"; @@ -400,4 +401,48 @@ describe("ContextualMenuDropdown ", () => { document.body.removeChild(grandparent); }); }); + + describe("focus behavior", () => { + it("focuses the first item when menu opens", async () => { + const links = Array.from({ length: 10 }).map((_, i) => i); + render(); + const dropdown: HTMLElement = screen.getByRole("menu"); + await waitFor(() => { + expect(dropdown).toBeInTheDocument(); + }); + + const firstItem = dropdown.querySelector('[role="menuitem"]'); + await waitFor(() => { + expect(firstItem).toHaveFocus(); + }); + }); + + it("moves focus from the last item to the first item (focus trap)", async () => { + const userEventWithTimers = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + const links = Array.from({ length: 3 }).map((_, i) => ({ + children: `Item ${i}`, + })); + + render(); + + const dropdown: HTMLElement = screen.getByRole("menu"); + await waitFor(() => { + expect(dropdown).toBeInTheDocument(); + }); + + const menuItems = await screen.findAllByRole("menuitem"); + await waitFor(() => expect(menuItems[0]).toHaveFocus()); + + for (let i = 0; i < menuItems.length - 1; i++) { + await userEventWithTimers.tab(); + await waitFor(() => expect(menuItems[i + 1]).toHaveFocus()); + } + await userEventWithTimers.tab(); + + // Should wrap back to the first item + expect(menuItems[0]).toHaveFocus(); + }); + }); }); diff --git a/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.tsx b/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.tsx index f98e0e468..bdccd4bcf 100644 --- a/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.tsx +++ b/src/components/ContextualMenu/ContextualMenuDropdown/ContextualMenuDropdown.tsx @@ -40,11 +40,13 @@ export type Props = { scrollOverflow?: boolean; setAdjustedPosition?: (position: Position) => void; contextualMenuClassName?: string; + focusFirstItemOnOpen?: boolean; } & HTMLProps; /** * Calculate the styles for the menu. * @param position - The menu position. + * @param verticalPosition - The vertical position (top or bottom). * @param positionCoords - The coordinates of the position node. * @param constrainPanelWidth - Whether the menu width should be constrained to the position width. */ @@ -145,6 +147,7 @@ const generateLink = ( className={classNames("p-contextual-menu__link", className)} key={key} + role={props.role || "menuitem"} onClick={ onClick ? (evt) => { @@ -214,6 +217,7 @@ const ContextualMenuDropdown = ({ scrollOverflow, setAdjustedPosition, contextualMenuClassName, + focusFirstItemOnOpen = true, ...props }: Props): React.JSX.Element => { const dropdown = useRef(null); @@ -240,6 +244,56 @@ const ContextualMenuDropdown = ({ ); }, [adjustedPosition, positionCoords, verticalPosition, constrainPanelWidth]); + 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"])'; + + const getFocusableElements = useCallback(() => { + if (!dropdown.current) return []; + return Array.from( + dropdown.current.querySelectorAll(focusableElementSelectors), + ) as HTMLElement[]; + }, [dropdown]); + + const focusFirstItem = useCallback(() => { + if (!focusFirstItemOnOpen) return; + requestAnimationFrame(() => { + const [firstItem] = getFocusableElements(); + if (firstItem) { + firstItem.focus(); + } + }); + }, [getFocusableElements, focusFirstItemOnOpen]); + + // Focus trap: handle Tab/Shift+Tab to keep focus inside dropdown + useEffect(() => { + if (!isOpen || !dropdown.current) return undefined; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + const items = getFocusableElements(); + if (items.length === 0) return; + const active = document.activeElement; + const first = items[0]; + const last = items[items.length - 1]; + if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } else if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } + }; + const node = dropdown.current; + node.addEventListener("keydown", handleKeyDown); + return () => { + node.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, getFocusableElements]); + + useEffect(() => { + if (!isOpen || !dropdown.current) return undefined; + focusFirstItem(); + }, [dropdown, focusFirstItem, isOpen]); + const updateVerticalPosition = useCallback(() => { if (!positionNode) { return; @@ -334,6 +388,7 @@ const ContextualMenuDropdown = ({ aria-hidden={isOpen ? "false" : "true"} aria-label={Label.Dropdown} ref={dropdown} + role={props.role || "menu"} style={{ ...(constrainPanelWidth && positionStyle?.width ? { width: positionStyle.width, minWidth: 0, maxWidth: "none" } @@ -352,7 +407,11 @@ const ContextualMenuDropdown = ({ : links.map((item, i) => { if (Array.isArray(item)) { return ( - + {item.map((link, j) => generateLink(link, j, handleClose), )} diff --git a/src/components/ContextualMenu/ContextualMenuDropdown/__snapshots__/ContextualMenuDropdown.test.tsx.snap b/src/components/ContextualMenu/ContextualMenuDropdown/__snapshots__/ContextualMenuDropdown.test.tsx.snap index 7bf9325ee..7344cdb38 100644 --- a/src/components/ContextualMenu/ContextualMenuDropdown/__snapshots__/ContextualMenuDropdown.test.tsx.snap +++ b/src/components/ContextualMenu/ContextualMenuDropdown/__snapshots__/ContextualMenuDropdown.test.tsx.snap @@ -6,6 +6,7 @@ exports[`ContextualMenuDropdown renders 1`] = ` aria-label="submenu" class="p-contextual-menu__dropdown" data-testid="dropdown" + role="menu" >
= ({ } }} position="left" + focusFirstItemOnOpen={false} constrainPanelWidth toggle={ variant === "search" ? ( From 35ee67ecd8eb620f6eff707d7ba021a83e640da9 Mon Sep 17 00:00:00 2001 From: Julie Muzina Date: Wed, 14 Jan 2026 17:59:49 -0500 Subject: [PATCH 2/5] opt customselect out of context menu autofocus --- src/components/CustomSelect/CustomSelect.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/CustomSelect/CustomSelect.tsx b/src/components/CustomSelect/CustomSelect.tsx index cad62ae6e..e2b98472a 100644 --- a/src/components/CustomSelect/CustomSelect.tsx +++ b/src/components/CustomSelect/CustomSelect.tsx @@ -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", From 4f3bfdba03afdbca3182a176dabcff61a7c55f60 Mon Sep 17 00:00:00 2001 From: Julie Muzina Date: Thu, 22 Jan 2026 19:01:43 -0500 Subject: [PATCH 3/5] ContextualMenu uses `open` event instead of effects for first focus This reverts commit 20eafa55b95f26caef5e749ada192556bb1bef96. Context menu focus events fired by open callback instead of useeffect simplify tests --- .../ContextualMenu/ContextualMenu.test.tsx | 114 +++++++++++++-- .../ContextualMenu/ContextualMenu.tsx | 133 +++++++++++++++--- .../ContextualMenuDropdown.test.tsx | 45 ------ .../ContextualMenuDropdown.tsx | 61 +------- .../ContextualMenuDropdown.test.tsx.snap | 1 - src/components/MultiSelect/MultiSelect.tsx | 1 - 6 files changed, 221 insertions(+), 134 deletions(-) diff --git a/src/components/ContextualMenu/ContextualMenu.test.tsx b/src/components/ContextualMenu/ContextualMenu.test.tsx index ca9fccc9a..d113903a8 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,15 +137,17 @@ describe("ContextualMenu ", () => { it("can display links", () => { render(); - expect(screen.getByRole("menuitem", { name: "Link1" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Link1" })).toBeInTheDocument(); }); it("can display links in groups", () => { render(); - const group = screen.getByRole("group"); + const group = document.querySelector( + ".p-contextual-menu__group", + ) as HTMLElement; expect(group).toBeInTheDocument(); expect( - within(group).getByRole("menuitem", { name: "Link1" }), + within(group).getByRole("button", { name: "Link1" }), ).toBeInTheDocument(); }); @@ -156,15 +158,17 @@ describe("ContextualMenu ", () => { visible />, ); - const group = screen.getByRole("group") as HTMLElement; + const group = document.querySelector( + ".p-contextual-menu__group", + ) as HTMLElement; expect(group).toBeInTheDocument(); expect( - within(group).getByRole("menuitem", { name: "Link1" }), + within(group).getByRole("button", { name: "Link1" }), ).toBeInTheDocument(); expect( - within(group).queryByRole("menuitem", { name: "Link2" }), + within(group).queryByRole("button", { name: "Link2" }), ).not.toBeInTheDocument(); - expect(screen.getByRole("menuitem", { name: "Link2" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Link2" })).toBeInTheDocument(); }); it("can supply content instead of links", () => { @@ -190,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("menuitem", { name: "Link1" })); + await userEvent.click(screen.getByRole("button", { name: "Link1" })); expect( screen.queryByLabelText(DropdownLabel.Dropdown), ).not.toBeInTheDocument(); @@ -297,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 5b3c591b4..b70f5d302 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,16 +79,16 @@ export type BaseProps = PropsWithSpread< * Whether the dropdown should scroll if it is too long to fit on the screen. */ scrollOverflow?: boolean; - /** - * Whether the menu should be visible. - */ - visible?: 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. + */ + visible?: boolean; }, HTMLProps >; @@ -188,10 +194,11 @@ const ContextualMenu = ({ 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); @@ -206,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 = () => { + 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); + }; + + /** + * Gets all focusable items in the dropdown element. + * @returns Array of focusable items in the dropdown element. + */ + const getFocusableDropdownItems = () => { + return Array.from( + getDropdown()?.querySelectorAll(focusableElementSelectors) || + [], + ); + }; + + /** + * 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 = () => { + const focusableElements = getFocusableDropdownItems(); + focusableElements[0]?.focus(); + }; + + /** + * 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(() => { + if (!isOpen) return undefined; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Tab") 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(); + dropdown.addEventListener("keydown", handleKeyDown); + return () => { + dropdown.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, getDropdown, getFocusableDropdownItems]); + const previousVisible = usePrevious(visible); const labelNode = toggleLabel && typeof toggleLabel === "string" ? ( @@ -303,7 +403,7 @@ const ContextualMenu = ({ toggleNode = (