import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { showContextMenuFallback } from "./contextMenuFallback"; type FakeListener = (event: FakeDomEvent) => void; class FakeDomEvent { defaultPrevented = false; constructor( readonly type: string, init: Record<string, unknown> = {}, ) { Object.assign(this, init); } preventDefault() { this.defaultPrevented = true; }} class FakeElement { children: FakeElement[] = []; parent: FakeElement | null = null; style: Record<string, string> & { cssText?: string } = {}; dataset: Record<string, string> = {}; className = ""; disabled = false; type = ""; private textValue = ""; private readonly listeners = new Map<string, FakeListener[]>(); constructor(readonly tagName: string) {} appendChild(child: FakeElement) { child.parent = this; this.children.push(child); return child; } remove() { if (!this.parent) { return; } const index = this.parent.children.indexOf(this); if (index >= 0) { this.parent.children.splice(index, 1); } this.parent = null; } addEventListener(type: string, listener: FakeListener) { const existing = this.listeners.get(type) ?? []; existing.push(listener); this.listeners.set(type, existing); } dispatchEvent(event: FakeDomEvent) { for (const listener of this.listeners.get(event.type) ?? []) { listener(event); } return true; } set textContent(value: string) { this.textValue = value; } get textContent() { return `${this.textValue}${this.children.map((child) => child.textContent).join("")}`; } querySelectorAll(tagName: string): FakeElement[] { const matches: FakeElement[] = []; if (this.tagName === tagName) { matches.push(this); } for (const child of this.children) { matches.push(...child.querySelectorAll(tagName)); } return matches; } getBoundingClientRect() { const left = Number.parseInt(this.style.left ?? "0", 10) || 0; const top = Number.parseInt(this.style.top ?? "0", 10) || 0; const width = this.tagName === "div" ? 180 : 140; const height = this.tagName === "div" ? 120 : 28; return { left, top, width, height, right: left + width, bottom: top + height, }; }} class FakeBody extends FakeElement { private html = ""; constructor() { super("body"); } set innerHTML(value: string) { this.html = value; this.children = []; } get innerHTML() { return this.html; }} class FakeDocument { body = new FakeBody(); private readonly listeners = new Map<string, FakeListener[]>(); createElement(tagName: string) { return new FakeElement(tagName); } addEventListener(type: string, listener: FakeListener) { const existing = this.listeners.get(type) ?? []; existing.push(listener); this.listeners.set(type, existing); } removeEventListener(type: string, listener: FakeListener) { const existing = this.listeners.get(type); if (!existing) { return; } const index = existing.indexOf(listener); if (index >= 0) { existing.splice(index, 1); } } querySelectorAll(tagName: string) { return this.body.querySelectorAll(tagName); }} function findButton(label: string): FakeElement | undefined { return (document as unknown as FakeDocument) .querySelectorAll("button") .find((button) => button.textContent.includes(label));} beforeEach(() => { vi.stubGlobal("document", new FakeDocument()); vi.stubGlobal("window", { innerWidth: 1280, innerHeight: 800, }); vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { callback(0); return 0; }); vi.stubGlobal( "MouseEvent", class extends FakeDomEvent { constructor(type: string, init: Record<string, unknown> = {}) { super(type, init); } }, ); vi.stubGlobal( "KeyboardEvent", class extends FakeDomEvent { constructor(type: string, init: Record<string, unknown> = {}) { super(type, init); } }, );}); afterEach(() => { vi.unstubAllGlobals();}); describe("showContextMenuFallback", () => { it("resolves a clicked flat menu item", async () => { const selectionPromise = showContextMenuFallback([ { id: "rename", label: "Rename" }, { id: "delete", label: "Delete", destructive: true }, ]); const renameButton = findButton("Rename"); expect(renameButton).toBeTruthy(); renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await expect(selectionPromise).resolves.toBe("rename"); }); it("ignores a click from the gesture that opened the menu", async () => { let enablePointerSelection: ((time: number) => void) | undefined; vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { enablePointerSelection = callback; return 0; }); const selectionPromise = showContextMenuFallback([{ id: "rename", label: "Rename" }]); const renameButton = findButton("Rename"); renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); enablePointerSelection?.(0); renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await expect(selectionPromise).resolves.toBe("rename"); }); it("opens nested submenus and resolves the clicked leaf id", async () => { const selectionPromise = showContextMenuFallback([ { id: "rename:submenu", label: "Rename project", children: [ { id: "rename:project-a", label: "/tmp/project-a" }, { id: "rename:project-b", label: "/tmp/project-b" }, ], }, ]); const parentButton = findButton("Rename project"); expect(parentButton).toBeTruthy(); parentButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); const childButton = findButton("/tmp/project-b"); expect(childButton).toBeTruthy(); childButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); await expect(selectionPromise).resolves.toBe("rename:project-b"); });});
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { showContextMenuFallback } from "./contextMenuFallback";
type FakeListener = (event: FakeDomEvent) => void;
class FakeDomEvent {
defaultPrevented = false;
constructor(
readonly type: string,
init: Record<string, unknown> = {},
) {
Object.assign(this, init);
}
preventDefault() {
this.defaultPrevented = true;
}
}
class FakeElement {
children: FakeElement[] = [];
parent: FakeElement | null = null;
style: Record<string, string> & { cssText?: string } = {};
dataset: Record<string, string> = {};
className = "";
disabled = false;
type = "";
private textValue = "";
private readonly listeners = new Map<string, FakeListener[]>();
constructor(readonly tagName: string) {}
appendChild(child: FakeElement) {
child.parent = this;
this.children.push(child);
return child;
}
remove() {
if (!this.parent) {
return;
}
const index = this.parent.children.indexOf(this);
if (index >= 0) {
this.parent.children.splice(index, 1);
}
this.parent = null;
}
addEventListener(type: string, listener: FakeListener) {
const existing = this.listeners.get(type) ?? [];
existing.push(listener);
this.listeners.set(type, existing);
}
dispatchEvent(event: FakeDomEvent) {
for (const listener of this.listeners.get(event.type) ?? []) {
listener(event);
}
return true;
}
set textContent(value: string) {
this.textValue = value;
}
get textContent() {
return `${this.textValue}${this.children.map((child) => child.textContent).join("")}`;
}
querySelectorAll(tagName: string): FakeElement[] {
const matches: FakeElement[] = [];
if (this.tagName === tagName) {
matches.push(this);
}
for (const child of this.children) {
matches.push(...child.querySelectorAll(tagName));
}
return matches;
}
getBoundingClientRect() {
const left = Number.parseInt(this.style.left ?? "0", 10) || 0;
const top = Number.parseInt(this.style.top ?? "0", 10) || 0;
const width = this.tagName === "div" ? 180 : 140;
const height = this.tagName === "div" ? 120 : 28;
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height,
};
}
}
class FakeBody extends FakeElement {
private html = "";
constructor() {
super("body");
}
set innerHTML(value: string) {
this.html = value;
this.children = [];
}
get innerHTML() {
return this.html;
}
}
class FakeDocument {
body = new FakeBody();
private readonly listeners = new Map<string, FakeListener[]>();
createElement(tagName: string) {
return new FakeElement(tagName);
}
addEventListener(type: string, listener: FakeListener) {
const existing = this.listeners.get(type) ?? [];
existing.push(listener);
this.listeners.set(type, existing);
}
removeEventListener(type: string, listener: FakeListener) {
const existing = this.listeners.get(type);
if (!existing) {
return;
}
const index = existing.indexOf(listener);
if (index >= 0) {
existing.splice(index, 1);
}
}
querySelectorAll(tagName: string) {
return this.body.querySelectorAll(tagName);
}
}
function findButton(label: string): FakeElement | undefined {
return (document as unknown as FakeDocument)
.querySelectorAll("button")
.find((button) => button.textContent.includes(label));
}
beforeEach(() => {
vi.stubGlobal("document", new FakeDocument());
vi.stubGlobal("window", {
innerWidth: 1280,
innerHeight: 800,
});
vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
callback(0);
return 0;
});
vi.stubGlobal(
"MouseEvent",
class extends FakeDomEvent {
constructor(type: string, init: Record<string, unknown> = {}) {
super(type, init);
}
},
);
vi.stubGlobal(
"KeyboardEvent",
class extends FakeDomEvent {
constructor(type: string, init: Record<string, unknown> = {}) {
super(type, init);
}
},
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("showContextMenuFallback", () => {
it("resolves a clicked flat menu item", async () => {
const selectionPromise = showContextMenuFallback([
{ id: "rename", label: "Rename" },
{ id: "delete", label: "Delete", destructive: true },
]);
const renameButton = findButton("Rename");
expect(renameButton).toBeTruthy();
renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await expect(selectionPromise).resolves.toBe("rename");
});
it("ignores a click from the gesture that opened the menu", async () => {
let enablePointerSelection: ((time: number) => void) | undefined;
vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
enablePointerSelection = callback;
return 0;
});
const selectionPromise = showContextMenuFallback([{ id: "rename", label: "Rename" }]);
const renameButton = findButton("Rename");
renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
enablePointerSelection?.(0);
renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await expect(selectionPromise).resolves.toBe("rename");
});
it("opens nested submenus and resolves the clicked leaf id", async () => {
const selectionPromise = showContextMenuFallback([
{
id: "rename:submenu",
label: "Rename project",
children: [
{ id: "rename:project-a", label: "/tmp/project-a" },
{ id: "rename:project-b", label: "/tmp/project-b" },
],
},
]);
const parentButton = findButton("Rename project");
expect(parentButton).toBeTruthy();
parentButton?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
const childButton = findButton("/tmp/project-b");
expect(childButton).toBeTruthy();
childButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await expect(selectionPromise).resolves.toBe("rename:project-b");
});
});