// @vitest-environment jsdom // // Contract of the reusable ⋯ overflow menu. Labels/handlers are plain props (no i18n), so we assert // the open/select/close + disabled-item behaviour. The menu content only mounts while open, and the // AnchoredPopover platform is floating-ui based, so we stub the two observers autoUpdate wires and // wrap in a FloatingTree (its real-app parent) so nested-layer dispatch has a node to register. import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; import ActionMenu, { type ActionMenuItem } from "./ActionMenu"; beforeAll(() => { // floating-ui's autoUpdate observes with ResizeObserver + IntersectionObserver (layoutShift); jsdom // ships neither, and the floating element mounts (so they run) the moment the menu opens. class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; }); afterEach(cleanup); const renderMenu = (items: ActionMenuItem[]) => render( , ); describe("ActionMenu", () => { it("renders a labelled trigger and keeps items unmounted until opened", () => { renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]); expect(screen.getByRole("button", { name: "Catalog actions" })).toBeTruthy(); expect(screen.queryByRole("menuitem")).toBeNull(); }); // floating-ui never computes a position in jsdom, so the surface stays visibility:hidden; the items // are in the DOM but the a11y tree treats them as hidden — hence `{ hidden: true }` on role queries. it("says why a disabled row is off, under its label — and only on a disabled row", () => { // A `title` on a disabled row reached nobody: a disabled button fires no pointer events and // roving focus skips it. The reason is visible text now, undimmed so it can be read. renderMenu([ { id: "move", label: "Move to", onSelect: vi.fn(), disabled: true, note: "No other list" }, { id: "sync", label: "Sync", onSelect: vi.fn(), note: "never shown" }, ]); fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); const [move, sync] = screen.getAllByRole("menuitem", { hidden: true }); expect(move!.textContent).toBe("Move toNo other list"); expect(sync!.textContent).toBe("Sync"); const note = screen.getByText("No other list"); expect(note.closest(".opacity-50")).toBeNull(); expect(screen.getByText("Move to").className).toContain("opacity-50"); }); it("opens on click and shows one menuitem per item", () => { renderMenu([ { id: "sync", label: "Sync", onSelect: vi.fn() }, { id: "backfill", label: "Backfill", onSelect: vi.fn() }, ]); fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); expect(screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent)).toEqual([ "Sync", "Backfill", ]); }); // Close first, act on a later task: floating-ui hands the focus back to the trigger from a microtask // as the menu unmounts, and an action run in that same batch (a confirm dialog) had its focus taken // back to the trigger behind the dialog's scrim — measured in Chrome, E11 S2. jsdom does not run // that focus race, so the ORDER is what is pinned: the menu is gone before the action runs. it("selecting an item closes the menu first, then fires its onSelect once", async () => { const onSelect = vi.fn(); renderMenu([{ id: "sync", label: "Sync", onSelect }]); fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); fireEvent.click(screen.getByText("Sync")); expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull(); expect(onSelect).not.toHaveBeenCalled(); await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1)); }); it("a disabled item is inert — rendered disabled and never fires onSelect", () => { const onSelect = vi.fn(); renderMenu([{ id: "sync", label: "Sync", onSelect, disabled: true }]); fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); const item = screen.getByText("Sync").closest("button")!; expect((item as HTMLButtonElement).disabled).toBe(true); fireEvent.click(item); expect(onSelect).not.toHaveBeenCalled(); }); it("skips a disabled item in roving keyboard nav", () => { // items[0] disabled, items[1] + items[2] enabled. Roving keyboard focus (the `tabindex=0` item, // which tracks useListNavigation's activeIndex) must NEVER land on the disabled index-0 — arrowing // skips straight over it. The skip comes for free: PopoverItem renders a native `disabled` button // and useListNavigation reads that DOM state (no explicit disabledIndices needed). Without it, // ArrowUp/Down would strand roving focus on the inert index-0 button. (floating-ui wires this on // real key nav, not on open, in jsdom — so we drive it with arrow keys.) renderMenu([ { id: "sync", label: "Sync", onSelect: vi.fn(), disabled: true }, { id: "backfill", label: "Backfill", onSelect: vi.fn() }, { id: "reset", label: "Reset", onSelect: vi.fn() }, ]); fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); const [disabled, backfill, reset] = screen.getAllByRole("menuitem", { hidden: true }) as [ HTMLElement, HTMLElement, HTMLElement, ]; const menu = screen.getByRole("menu", { hidden: true }); const rover = () => [disabled, backfill, reset].find((el) => el.getAttribute("tabindex") === "0")?.textContent; // The disabled item never becomes the roving target, whichever way we arrow. fireEvent.keyDown(menu, { key: "ArrowDown" }); // skips disabled[0] -> first enabled expect(rover()).toBe("Backfill"); expect(disabled.getAttribute("tabindex")).toBe("-1"); fireEvent.keyDown(menu, { key: "ArrowDown" }); expect(rover()).toBe("Reset"); fireEvent.keyDown(menu, { key: "ArrowUp" }); // back to Backfill expect(rover()).toBe("Backfill"); fireEvent.keyDown(menu, { key: "ArrowUp" }); // wraps over disabled[0] to the last item expect(rover()).toBe("Reset"); expect(disabled.getAttribute("tabindex")).toBe("-1"); // still never the disabled item }); it("honours a custom trigger node", () => { renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]); cleanup(); render( •••} /> , ); expect(screen.getByTestId("custom-trigger")).toBeTruthy(); }); }); // PageUp/PageDown in a roving menu (E11, owner request) — the same page as the palette's and the // combobox's (lib/listPaging); here the keyboard's row holds real DOM focus (the `tabindex=0` rover). describe("ActionMenu · paging", () => { it("moves the roving focus a page at a time, over disabled rows, stopping at the ends", () => { renderMenu( Array.from({ length: 25 }, (_, i) => ({ id: `r${i}`, label: `Row ${i}`, onSelect: vi.fn(), disabled: i === 20, })), ); fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); const menu = screen.getByRole("menu", { hidden: true }); const rover = () => screen .getAllByRole("menuitem", { hidden: true }) .find((el) => el.getAttribute("tabindex") === "0")?.textContent; expect(fireEvent.keyDown(menu, { key: "PageDown" })).toBe(false); expect(rover()).toBe("Row 10"); fireEvent.keyDown(menu, { key: "PageDown" }); expect(rover()).toBe("Row 21"); // Row 20 is disabled fireEvent.keyDown(menu, { key: "PageDown" }); expect(rover()).toBe("Row 24"); fireEvent.keyDown(menu, { key: "PageUp" }); expect(rover()).toBe("Row 14"); }); it("pages only the submenu the key was pressed in, not the menu around it", () => { // A submenu's surface sits inside its parent's in the React tree, so the key bubbles through the // parent's handler too; paging both would drag the focus back out to the parent's row. renderMenu([ { id: "move", label: "Move to", items: Array.from({ length: 25 }, (_, i) => ({ id: `s${i}`, label: `Sub ${i}`, onSelect: vi.fn(), })), }, ...Array.from({ length: 12 }, (_, i) => ({ id: `p${i}`, label: `Top ${i}`, onSelect: vi.fn(), })), ]); fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); fireEvent.click(screen.getByText("Move to")); const rovers = () => screen .getAllByRole("menuitem", { hidden: true }) .filter((el) => el.getAttribute("tabindex") === "0") .map((el) => el.textContent ?? ""); const outer = () => rovers().filter((label) => !label.startsWith("Sub")); const before = outer(); fireEvent.keyDown(screen.getByText("Sub 0").closest("button")!, { key: "PageDown" }); expect(rovers()).toContain("Sub 10"); expect(outer()).toEqual(before); }); });