Skip to content
Source
Sign inSign up

contextMenuFallback.test.ts

248 lines6.2 KB

1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2​
3import { 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 }
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 }
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 }
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 }
181​
182function findButton(label: string): FakeElement | undefined {
183 return (document as unknown as FakeDocument)
184 .querySelectorAll("button")
185 .find((button) => button.textContent.includes(label));
187​
188beforeEach(() => {
189 vi.stubGlobal("document", new FakeDocument());
190 vi.stubGlobal("HTMLElement", FakeElement);
191 vi.stubGlobal("window", {
192 innerWidth: 1280,
193 innerHeight: 800,
194 addEventListener: vi.fn(),
195 removeEventListener: vi.fn(),
196 });
197 vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
198 callback(0);
199 return 0;
200 });
201 vi.stubGlobal(
202 "MouseEvent",
203 class extends FakeDomEvent {
204 constructor(type: string, init: Record<string, unknown> = {}) {
205 super(type, init);
206 }
207 },
208 );
209 vi.stubGlobal(
210 "KeyboardEvent",
211 class extends FakeDomEvent {
212 constructor(type: string, init: Record<string, unknown> = {}) {
213 super(type, init);
214 }
215 },
216 );
217});
218​
219afterEach(() => {
220 vi.unstubAllGlobals();
221});
222​
223describe("showContextMenuFallback", () => {
224 it("ignores a click from the gesture that opened the menu", async () => {
225 let enablePointerSelection: ((time: number) => void) | undefined;
226 vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
227 enablePointerSelection = callback;
228 return 0;
229 });
230​
231 const selectionPromise = showContextMenuFallback([{ id: "rename", label: "Rename" }]);
232 const renameButton = findButton("Rename");
233​
234 renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
235 enablePointerSelection?.(0);
236 renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
237​
238 await expect(selectionPromise).resolves.toBe("rename");
239 });
240​
241 it("shows an item's detail beside its label and reads both to screen readers", () => {
242 void showContextMenuFallback([{ id: "snooze:hour", label: "In 1 hour", detail: "3:00 PM" }]);
243 const button = findButton("In 1 hour");
244​
245 expect(button?.textContent).toBe("In 1 hour3:00 PM");
246 expect(button?.attributes.get("aria-label")).toBe("In 1 hour, 3:00 PM");
247 });
248});