1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback";
4
5type FakeListener = (event: FakeDomEvent) => void;
6
7class FakeDomEvent {
8 defaultPrevented = false;
9
10 constructor(
11 readonly type: string,
12 init: Record<string, unknown> = {},
13 ) {
14 Object.assign(this, init);
15 }
16
17 preventDefault() {
18 this.defaultPrevented = true;
19 }
20}
21
22class FakeElement {
23 children: FakeElement[] = [];
24 parent: FakeElement | null = null;
25 style: Record<string, string> & { cssText?: string } = {};
26 dataset: Record<string, string> = {};
27 attributes = new Map<string, string>();
28 className = "";
29 disabled = false;
30 focused = false;
31 type = "";
32 private textValue = "";
33 private readonly listeners = new Map<string, FakeListener[]>();
34
35 constructor(readonly tagName: string) {}
36
37 get isConnected() {
38 let current: FakeElement | null = this;
39 while (current?.parent) {
40 current = current.parent;
41 }
42 return current?.tagName === "body";
43 }
44
45 appendChild(child: FakeElement) {
46 child.parent = this;
47 this.children.push(child);
48 return child;
49 }
50
51 remove() {
52 if (!this.parent) {
53 return;
54 }
55 const index = this.parent.children.indexOf(this);
56 if (index >= 0) {
57 this.parent.children.splice(index, 1);
58 }
59 this.parent = null;
60 }
61
62 addEventListener(type: string, listener: FakeListener) {
63 const existing = this.listeners.get(type) ?? [];
64 existing.push(listener);
65 this.listeners.set(type, existing);
66 }
67
68 setAttribute(name: string, value: string) {
69 this.attributes.set(name, value);
70 }
71
72 dispatchEvent(event: FakeDomEvent) {
73 for (const listener of this.listeners.get(event.type) ?? []) {
74 listener(event);
75 }
76 return true;
77 }
78
79 focus() {
80 const fakeDocument = document as unknown as FakeDocument;
81 if (fakeDocument.activeElement === this) {
82 return;
83 }
84 fakeDocument.activeElement?.blur();
85 fakeDocument.activeElement = this;
86 this.focused = true;
87 this.dispatchEvent(new FakeDomEvent("focus"));
88 }
89
90 blur() {
91 const fakeDocument = document as unknown as FakeDocument;
92 if (fakeDocument.activeElement === this) {
93 fakeDocument.activeElement = null;
94 }
95 this.focused = false;
96 this.dispatchEvent(new FakeDomEvent("blur"));
97 }
98
99 set textContent(value: string) {
100 this.textValue = value;
101 }
102
103 get textContent() {
104 return `${this.textValue}${this.children.map((child) => child.textContent).join("")}`;
105 }
106
107 querySelectorAll(tagName: string): FakeElement[] {
108 const matches: FakeElement[] = [];
109 if (this.tagName === tagName) {
110 matches.push(this);
111 }
112 for (const child of this.children) {
113 matches.push(...child.querySelectorAll(tagName));
114 }
115 return matches;
116 }
117
118 getBoundingClientRect() {
119 const left = Number.parseInt(this.style.left ?? "0", 10) || 0;
120 const top = Number.parseInt(this.style.top ?? "0", 10) || 0;
121 const width = this.tagName === "div" ? 180 : 140;
122 const height = this.tagName === "div" ? 120 : 28;
123 return {
124 left,
125 top,
126 width,
127 height,
128 right: left + width,
129 bottom: top + height,
130 };
131 }
132}
133
134class FakeBody extends FakeElement {
135 private html = "";
136
137 constructor() {
138 super("body");
139 }
140
141 set innerHTML(value: string) {
142 this.html = value;
143 this.children = [];
144 }
145
146 get innerHTML() {
147 return this.html;
148 }
149}
150
151class FakeDocument {
152 body = new FakeBody();
153 activeElement: FakeElement | null = null;
154 private readonly listeners = new Map<string, FakeListener[]>();
155
156 createElement(tagName: string) {
157 return new FakeElement(tagName);
158 }
159
160 addEventListener(type: string, listener: FakeListener) {
161 const existing = this.listeners.get(type) ?? [];
162 existing.push(listener);
163 this.listeners.set(type, existing);
164 }
165
166 removeEventListener(type: string, listener: FakeListener) {
167 const existing = this.listeners.get(type);
168 if (!existing) {
169 return;
170 }
171 const index = existing.indexOf(listener);
172 if (index >= 0) {
173 existing.splice(index, 1);
174 }
175 }
176
177 querySelectorAll(tagName: string) {
178 return this.body.querySelectorAll(tagName);
179 }
180}
181
182function findButton(label: string): FakeElement | undefined {
183 return (document as unknown as FakeDocument)
184 .querySelectorAll("button")
185 .find((button) => button.textContent.includes(label));
186}
187
188beforeEach(() => {
189 vi.stubGlobal("document", new FakeDocument());
190 vi.stubGlobal("HTMLElement", FakeElement);
191 vi.stubGlobal("window", {
192 innerWidth: 1280,
193 innerHeight: 800,
194 });
195 vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
196 callback(0);
197 return 0;
198 });
199 vi.stubGlobal(
200 "MouseEvent",
201 class extends FakeDomEvent {
202 constructor(type: string, init: Record<string, unknown> = {}) {
203 super(type, init);
204 }
205 },
206 );
207 vi.stubGlobal(
208 "KeyboardEvent",
209 class extends FakeDomEvent {
210 constructor(type: string, init: Record<string, unknown> = {}) {
211 super(type, init);
212 }
213 },
214 );
215});
216
217afterEach(() => {
218 vi.unstubAllGlobals();
219});
220
221describe("showContextMenuFallback", () => {
222 it("renders one separator between menu sections", async () => {
223 const selectionPromise = showContextMenuFallback([
224 { id: "rename", label: "Rename" },
225 { id: "archive", label: "Archive", separatorBefore: true },
226 ]);
227 const separators = (document as unknown as FakeDocument)
228 .querySelectorAll("div")
229 .filter((element) => element.dataset.contextMenuSeparator === "true");
230
231 expect(separators).toHaveLength(1);
232 expect(separators[0]?.attributes.get("role")).toBe("separator");
233 dismissContextMenu();
234 await expect(selectionPromise).resolves.toBeNull();
235 });
236
237 it("resolves a clicked flat menu item", async () => {
238 const selectionPromise = showContextMenuFallback([
239 { id: "rename", label: "Rename" },
240 { id: "delete", label: "Delete", destructive: true },
241 ]);
242
243 const renameButton = findButton("Rename");
244 expect(renameButton).toBeTruthy();
245 renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
246
247 await expect(selectionPromise).resolves.toBe("rename");
248 });
249
250 it("ignores a click from the gesture that opened the menu", async () => {
251 let enablePointerSelection: ((time: number) => void) | undefined;
252 vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
253 enablePointerSelection = callback;
254 return 0;
255 });
256
257 const selectionPromise = showContextMenuFallback([{ id: "rename", label: "Rename" }]);
258 const renameButton = findButton("Rename");
259
260 renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
261 enablePointerSelection?.(0);
262 renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
263
264 await expect(selectionPromise).resolves.toBe("rename");
265 });
266
267 it("opens nested submenus and resolves the clicked leaf id", async () => {
268 const selectionPromise = showContextMenuFallback([
269 {
270 id: "rename:submenu",
271 label: "Rename project",
272 children: [
273 { id: "rename:project-a", label: "/tmp/project-a" },
274 { id: "rename:project-b", label: "/tmp/project-b" },
275 ],
276 },
277 ]);
278
279 const parentButton = findButton("Rename project");
280 expect(parentButton).toBeTruthy();
281 parentButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
282
283 const childButton = findButton("/tmp/project-b");
284 expect(childButton).toBeTruthy();
285 childButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
286
287 await expect(selectionPromise).resolves.toBe("rename:project-b");
288 });
289
290 it("opens and focuses nested submenus when the parent is activated", async () => {
291 const invoker = (document as unknown as FakeDocument).createElement("button");
292 (document as unknown as FakeDocument).body.appendChild(invoker);
293 invoker.focus();
294 const selectionPromise = showContextMenuFallback([
295 {
296 id: "copy:submenu",
297 label: "Copy",
298 children: [
299 { id: "copy:path", label: "Path" },
300 { id: "copy:branch", label: "Branch" },
301 ],
302 },
303 ]);
304
305 const parentButton = findButton("Copy");
306 expect(parentButton).toBeTruthy();
307 expect(parentButton?.attributes.get("aria-haspopup")).toBe("menu");
308 expect(parentButton?.attributes.get("aria-expanded")).toBe("false");
309 parentButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
310 expect(parentButton?.attributes.get("aria-expanded")).toBe("true");
311
312 const childButton = findButton("Path");
313 const siblingButton = findButton("Branch");
314 expect(childButton).toBeTruthy();
315 expect(siblingButton).toBeTruthy();
316 expect(childButton?.focused).toBe(true);
317 expect(childButton?.style.background).toBe("var(--accent)");
318 expect(childButton?.style.color).toBe("var(--contrast-accent-foreground)");
319 siblingButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
320 expect(childButton?.focused).toBe(false);
321 expect(childButton?.style.background).toBe("transparent");
322 expect(siblingButton?.focused).toBe(true);
323 expect(siblingButton?.style.background).toBe("var(--accent)");
324 siblingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
325
326 await expect(selectionPromise).resolves.toBe("copy:branch");
327 expect(invoker.focused).toBe(true);
328 });
329});
330
331describe("dismissContextMenu", () => {
332 it("resolves an open menu with null", async () => {
333 const selectionPromise = showContextMenuFallback([
334 { id: "rename", label: "Rename" },
335 { id: "delete", label: "Delete" },
336 ]);
337 expect(findButton("Rename")).toBeTruthy();
338
339 dismissContextMenu();
340
341 await expect(selectionPromise).resolves.toBeNull();
342 expect(findButton("Rename")).toBeUndefined();
343 });
344
345 it("is a no-op when no menu is open", async () => {
346 dismissContextMenu();
347 expect(findButton("Rename")).toBeUndefined();
348 });
349
350 it("dismisses the prior menu when a new one opens", async () => {
351 const firstPromise = showContextMenuFallback([{ id: "first", label: "First" }]);
352 expect(findButton("First")).toBeTruthy();
353
354 const secondPromise = showContextMenuFallback([{ id: "second", label: "Second" }]);
355
356 await expect(firstPromise).resolves.toBeNull();
357 expect(findButton("First")).toBeUndefined();
358 expect(findButton("Second")).toBeTruthy();
359
360 dismissContextMenu();
361 await expect(secondPromise).resolves.toBeNull();
362 });
363});