// @vitest-environment jsdom /** * T-182 (P8 a11y pass) — the topbar "More" menu, WAI-ARIA menu-button pattern. * * The export/duplicate/delete surface is a core interaction (and the place * the P8 a11y requirement "Accessible keyboard navigation" / "Accessible * dialogs and forms" bites first), so its accessibility contract is pinned * here in the DOM (not just eyeballed in E2E): * * - the trigger is a menu BUTTON: `aria-haspopup="menu"` and * `aria-expanded` false→true→false as the menu opens/closes; * - opening the menu moves focus to the FIRST menu item; * - ArrowDown / ArrowUp wrap through the items; Home / End jump; * - Escape closes the menu and returns focus to the trigger; * - Tab is trapped (preventDefault) so focus cannot escape mid-menu. * * The component's data hooks (page/workspace/versions/notifications) are * mocked to the minimum the menu renders against — this test is about the * ARIA/keyboard behavior, not the data. */ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = { page: { id: "pg1", title: "The Page", favorite: false, kind: "page" as const, parentId: null }, workspace: { id: "ws1", name: "WS", slug: "ws" }, versions: { data: [], isLoading: false }, notifications: { unreadCount: 0, isLoading: false }, watching: false, toggleWatch: vi.fn(), toggleFavorite: vi.fn(), duplicate: vi.fn(), mobileNavOpen: false, toggleSidebar: vi.fn() }; vi.mock("next/navigation", () => ({ useParams: () => ({ workspace: "ws", id: "pg1" }), useRouter: () => ({ push: vi.fn() }) })); vi.mock("next/link", () => ({ default: ({ children }: { children: React.ReactNode }) => children })); vi.mock("@/hooks/use-workspace", () => ({ useWorkspace: () => ({ data: mocks.workspace }) })); vi.mock("@/hooks/use-pages", () => ({ usePage: () => ({ data: mocks.page }), usePageVersions: () => mocks.versions, useDuplicatePage: () => ({ mutateAsync: mocks.duplicate }), useToggleFavorite: () => mocks.toggleFavorite, usePageWatch: () => ({ data: { watching: mocks.watching } }), useToggleWatch: () => mocks.toggleWatch })); vi.mock("@/hooks/use-notifications", () => ({ useNotifications: () => mocks.notifications })); vi.mock("@/stores/ui-store", () => { const useUiStore = Object.assign( (sel: (s: Record) => unknown) => sel(mocks), { getState: () => mocks } ); return { useUiStore, toast: vi.fn() }; }); vi.mock("@/components/editor/history-popover", () => ({ HistoryPopover: () => null })); vi.mock("@/components/editor/version-compare-dialog", () => ({ VersionCompareDialog: () => null })); vi.mock("@/components/collaboration/StatusChip", () => ({ StatusChip: () => null })); import { Topbar } from "../topbar"; let root: Root; let host: HTMLElement; const mount = () => act(() => { root.render(); }); const trigger = () => document.querySelector('[aria-label="More actions"]')!; const menuItems = () => Array.from(document.querySelectorAll("[role='menuitem']")); beforeEach(() => { host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); mocks.mobileNavOpen = false; mocks.toggleWatch.mockClear(); mocks.toggleFavorite.mockClear(); mocks.duplicate.mockClear(); }); afterEach(() => { act(() => root.unmount()); host.remove(); vi.restoreAllMocks(); }); const openMenu = () => act(() => { trigger().dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const key = (el: Element, k: string) => act(() => { el.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true })); }); describe("topbar More menu — ARIA menu-button pattern", () => { it("the trigger is a menu button (haspopup + expanded=false while closed)", () => { mount(); expect(trigger().getAttribute("aria-haspopup")).toBe("menu"); expect(trigger().getAttribute("aria-expanded")).toBe("false"); // the menu is not rendered while closed expect(document.querySelector("[role='menu']")).toBeNull(); }); it("opening expands it, moves focus to the first item, and the menu has role=menu", () => { mount(); openMenu(); expect(trigger().getAttribute("aria-expanded")).toBe("true"); expect(document.querySelector("[role='menu']")).toBeTruthy(); const items = menuItems(); expect(items.length).toBeGreaterThanOrEqual(4); expect(document.activeElement).toBe(items[0]); }); it("ArrowDown / ArrowUp navigate (with wrap); Home / End jump", () => { mount(); openMenu(); const items = menuItems(); // start at first expect(document.activeElement).toBe(items[0]); key(document.body, "ArrowDown"); expect(document.activeElement).toBe(items[1]); key(items[1], "ArrowUp"); expect(document.activeElement).toBe(items[0]); key(items[0], "ArrowUp"); // wraps to LAST expect(document.activeElement).toBe(items[items.length - 1]); key(document.body, "Home"); expect(document.activeElement).toBe(items[0]); key(document.body, "End"); expect(document.activeElement).toBe(items[items.length - 1]); }); it("Escape closes the menu and returns focus to the trigger", () => { mount(); openMenu(); expect(document.querySelector("[role='menu']")).toBeTruthy(); key(document.body, "Escape"); expect(document.querySelector("[role='menu']")).toBeNull(); expect(trigger().getAttribute("aria-expanded")).toBe("false"); expect(document.activeElement).toBe(trigger()); }); it("Tab is trapped while the menu is open (preventDefault, no focus escape)", () => { mount(); openMenu(); const ev = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }); let defaultPrevented = false; act(() => { document.body.dispatchEvent(ev); defaultPrevented = ev.defaultPrevented; }); expect(defaultPrevented).toBe(true); }); it("clicking outside closes the menu (existing behavior preserved)", () => { mount(); openMenu(); expect(document.querySelector("[role='menu']")).toBeTruthy(); act(() => { document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); }); expect(document.querySelector("[role='menu']")).toBeNull(); }); });