1
2
21(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
22import { act } from "react";
23import { createRoot, type Root } from "react-dom/client";
24import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
25
26const mocks = {
27 page: {
28 id: "pg1",
29 title: "The Page",
30 favorite: false,
31 kind: "page" as const,
32 parentId: null
33 },
34 workspace: { id: "ws1", name: "WS", slug: "ws" },
35 versions: { data: [], isLoading: false },
36 notifications: { unreadCount: 0, isLoading: false },
37 watching: false,
38 toggleWatch: vi.fn(),
39 toggleFavorite: vi.fn(),
40 duplicate: vi.fn(),
41 mobileNavOpen: false,
42 toggleSidebar: vi.fn()
43};
44
45vi.mock("next/navigation", () => ({
46 useParams: () => ({ workspace: "ws", id: "pg1" }),
47 useRouter: () => ({ push: vi.fn() })
48}));
49vi.mock("next/link", () => ({ default: ({ children }: { children: React.ReactNode }) => children }));
50vi.mock("@/hooks/use-workspace", () => ({
51 useWorkspace: () => ({ data: mocks.workspace })
52}));
53vi.mock("@/hooks/use-pages", () => ({
54 usePage: () => ({ data: mocks.page }),
55 usePageVersions: () => mocks.versions,
56 useDuplicatePage: () => ({ mutateAsync: mocks.duplicate }),
57 useToggleFavorite: () => mocks.toggleFavorite,
58 usePageWatch: () => ({ data: { watching: mocks.watching } }),
59 useToggleWatch: () => mocks.toggleWatch
60}));
61vi.mock("@/hooks/use-notifications", () => ({
62 useNotifications: () => mocks.notifications
63}));
64vi.mock("@/stores/ui-store", () => {
65 const useUiStore = Object.assign(
66 (sel: (s: Record<string, unknown>) => unknown) => sel(mocks),
67 { getState: () => mocks }
68 );
69 return { useUiStore, toast: vi.fn() };
70});
71vi.mock("@/components/editor/history-popover", () => ({ HistoryPopover: () => null }));
72vi.mock("@/components/editor/version-compare-dialog", () => ({ VersionCompareDialog: () => null }));
73vi.mock("@/components/collaboration/StatusChip", () => ({ StatusChip: () => null }));
74
75import { Topbar } from "../topbar";
76
77let root: Root;
78let host: HTMLElement;
79
80const mount = () =>
81 act(() => {
82 root.render(<Topbar />);
83 });
84
85const trigger = () => document.querySelector<HTMLButtonElement>('[aria-label="More actions"]')!;
86const menuItems = () =>
87 Array.from(document.querySelectorAll<HTMLButtonElement>("[role='menuitem']"));
88
89beforeEach(() => {
90 host = document.createElement("div");
91 document.body.appendChild(host);
92 root = createRoot(host);
93 mocks.mobileNavOpen = false;
94 mocks.toggleWatch.mockClear();
95 mocks.toggleFavorite.mockClear();
96 mocks.duplicate.mockClear();
97});
98
99afterEach(() => {
100 act(() => root.unmount());
101 host.remove();
102 vi.restoreAllMocks();
103});
104
105const openMenu = () =>
106 act(() => {
107 trigger().dispatchEvent(new MouseEvent("click", { bubbles: true }));
108 });
109
110const key = (el: Element, k: string) =>
111 act(() => {
112 el.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true }));
113 });
114
115describe("topbar More menu — ARIA menu-button pattern", () => {
116 it("the trigger is a menu button (haspopup + expanded=false while closed)", () => {
117 mount();
118 expect(trigger().getAttribute("aria-haspopup")).toBe("menu");
119 expect(trigger().getAttribute("aria-expanded")).toBe("false");
120
121 expect(document.querySelector("[role='menu']")).toBeNull();
122 });
123
124 it("opening expands it, moves focus to the first item, and the menu has role=menu", () => {
125 mount();
126 openMenu();
127 expect(trigger().getAttribute("aria-expanded")).toBe("true");
128 expect(document.querySelector("[role='menu']")).toBeTruthy();
129 const items = menuItems();
130 expect(items.length).toBeGreaterThanOrEqual(4);
131 expect(document.activeElement).toBe(items[0]);
132 });
133
134 it("ArrowDown / ArrowUp navigate (with wrap); Home / End jump", () => {
135 mount();
136 openMenu();
137 const items = menuItems();
138
139 expect(document.activeElement).toBe(items[0]);
140 key(document.body, "ArrowDown");
141 expect(document.activeElement).toBe(items[1]);
142 key(items[1], "ArrowUp");
143 expect(document.activeElement).toBe(items[0]);
144 key(items[0], "ArrowUp");
145 expect(document.activeElement).toBe(items[items.length - 1]);
146 key(document.body, "Home");
147 expect(document.activeElement).toBe(items[0]);
148 key(document.body, "End");
149 expect(document.activeElement).toBe(items[items.length - 1]);
150 });
151
152 it("Escape closes the menu and returns focus to the trigger", () => {
153 mount();
154 openMenu();
155 expect(document.querySelector("[role='menu']")).toBeTruthy();
156 key(document.body, "Escape");
157 expect(document.querySelector("[role='menu']")).toBeNull();
158 expect(trigger().getAttribute("aria-expanded")).toBe("false");
159 expect(document.activeElement).toBe(trigger());
160 });
161
162 it("Tab is trapped while the menu is open (preventDefault, no focus escape)", () => {
163 mount();
164 openMenu();
165 const ev = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
166 let defaultPrevented = false;
167 act(() => {
168 document.body.dispatchEvent(ev);
169 defaultPrevented = ev.defaultPrevented;
170 });
171 expect(defaultPrevented).toBe(true);
172 });
173
174 it("clicking outside closes the menu (existing behavior preserved)", () => {
175 mount();
176 openMenu();
177 expect(document.querySelector("[role='menu']")).toBeTruthy();
178 act(() => {
179 document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
180 });
181 expect(document.querySelector("[role='menu']")).toBeNull();
182 });
183});
184