// @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("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", ]); }); it("selecting an item fires its onSelect and closes the menu", () => { const onSelect = vi.fn(); renderMenu([{ id: "sync", label: "Sync", onSelect }]); fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); fireEvent.click(screen.getByText("Sync")); expect(onSelect).toHaveBeenCalledTimes(1); expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull(); }); 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("honours a custom trigger node", () => { renderMenu([{ id: "sync", label: "Sync", onSelect: vi.fn() }]); cleanup(); render( •••} /> , ); expect(screen.getByTestId("custom-trigger")).toBeTruthy(); }); });