1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import { observeSelectionActions, resolveSelectionActionPosition } from "./selectionActions";
4
5function event(type: string, values: Record<string, unknown> = {}) {
6 return Object.assign(new Event(type, { cancelable: true }), values);
7}
8
9function createSelectionSurface({ interactiveActions = false } = {}) {
10 const frames = new Map<number, FrameRequestCallback>();
11 let frameId = 0;
12 const view = Object.assign(new EventTarget(), {
13 setTimeout: (callback: () => void, delay: number) => setTimeout(callback, delay),
14 clearTimeout: (id: ReturnType<typeof setTimeout>) => clearTimeout(id),
15 requestAnimationFrame: (callback: FrameRequestCallback) => {
16 frames.set(++frameId, callback);
17 return frameId;
18 },
19 cancelAnimationFrame: (id: number) => frames.delete(id),
20 });
21 const document = Object.assign(new EventTarget(), {
22 defaultView: view,
23 activeElement: null as EventTarget | null,
24 });
25 const element = Object.assign(new EventTarget(), {
26 ownerDocument: document,
27 contains: (target: unknown): boolean => target === element || target === buttonIcon,
28 closest: () => null,
29 });
30 const button = new EventTarget();
31 const buttonIcon = Object.assign(new EventTarget(), {
32 closest: (selector: string) => (selector === "button, [role=button]" ? button : null),
33 });
34 const onSelection = vi.fn();
35 const onDismiss = vi.fn();
36 const field = new EventTarget();
37 const actionElement = Object.assign(new EventTarget(), {
38 contains: (target: unknown) => target === actionElement || target === field,
39 });
40 const actions = observeSelectionActions({
41 element: element as unknown as HTMLElement,
42 ...(interactiveActions
43 ? { getActionElement: () => actionElement as unknown as HTMLElement }
44 : {}),
45 onSelection,
46 onDismiss,
47 });
48 document.addEventListener("selectionchange", actions.selectionChanged);
49 const change = () => document.dispatchEvent(event("selectionchange"));
50 const down = ({
51 inside = true,
52 button = 0,
53 consumed = false,
54 isPrimary = true,
55 target = inside ? element : document,
56 }: {
57 inside?: boolean;
58 button?: number;
59 consumed?: boolean;
60 isPrimary?: boolean;
61 target?: EventTarget;
62 } = {}) => {
63 const press = event("pointerdown", { button, isPrimary });
64 Object.defineProperty(press, "target", { value: target });
65 document.dispatchEvent(press);
66 if (element.contains(target) && !consumed) element.dispatchEvent(press);
67 };
68 const focus = (target: EventTarget) => {
69 document.activeElement = target;
70 const focusEvent = event("focusin");
71 Object.defineProperty(focusEvent, "target", { value: target });
72 document.dispatchEvent(focusEvent);
73 };
74 const key = (key: string, target: EventTarget = document) => {
75 const keyEvent = event("keydown", { key });
76 Object.defineProperty(keyEvent, "target", { value: target });
77 document.dispatchEvent(keyEvent);
78 };
79 const pointerUp = (button = 0) => {
80 view.dispatchEvent(event("pointerup", { isPrimary: true, button }));
81 };
82 const up = ({ x = 300, y = 200, detail = 1, button = 0 } = {}) => {
83 pointerUp(button);
84 view.dispatchEvent(event("mouseup", { clientX: x, clientY: y, detail, button }));
85 };
86 const flushFrame = () => {
87 const callbacks = [...frames.values()];
88 frames.clear();
89 for (const callback of callbacks) callback(0);
90 };
91 const flush = (milliseconds = 0) => {
92 vi.advanceTimersByTime(milliseconds);
93 flushFrame();
94 };
95 return {
96 actions,
97 view,
98 document,
99 element,
100 buttonIcon,
101 actionElement,
102 field,
103 onSelection,
104 onDismiss,
105 down,
106 focus,
107 key,
108 up,
109 pointerUp,
110 change,
111 flush,
112 flushFrame,
113 };
114}
115
116beforeEach(() => vi.useFakeTimers());
117afterEach(() => vi.useRealTimers());
118
119describe("selection action gestures", () => {
120 it("stays hidden through selection changes while dragging and uses the release point", () => {
121 const surface = createSelectionSurface();
122 surface.down();
123 surface.change();
124 surface.flush(1000);
125 surface.change();
126 surface.flush(1000);
127 expect(surface.onSelection).not.toHaveBeenCalled();
128 surface.up({ x: 420, y: 310 });
129 surface.change();
130 surface.flush();
131 expect(surface.onSelection.mock.calls).toEqual([[{ x: 420, y: 310 }]]);
132 });
133
134 it("preserves the release anchor if the final selectionchange arrives after the menu opens", () => {
135 const surface = createSelectionSurface();
136 surface.down();
137 surface.up({ x: 180, y: 120 });
138 surface.flush();
139 surface.change();
140 surface.flush();
141 expect(surface.onSelection).toHaveBeenLastCalledWith({ x: 180, y: 120 });
142 });
143
144 it("lets a third click supersede the double-click popup before it opens", () => {
145 const surface = createSelectionSurface();
146 surface.down();
147 surface.up({ detail: 2 });
148 surface.change();
149 surface.flush(400);
150 expect(surface.onSelection).not.toHaveBeenCalled();
151 surface.down();
152 surface.change();
153 surface.flush(500);
154 expect(surface.onSelection).not.toHaveBeenCalled();
155 surface.up({ detail: 3, x: 310, y: 210 });
156 surface.change();
157 surface.flush(499);
158 expect(surface.onSelection).not.toHaveBeenCalled();
159 surface.flush(1);
160 expect(surface.onSelection.mock.calls).toEqual([[{ x: 310, y: 210 }]]);
161 });
162
163 it("accepts release outside the surface but never a gesture that started outside", () => {
164 const surface = createSelectionSurface();
165 surface.down({ inside: false });
166 surface.change();
167 surface.up();
168 surface.change();
169 surface.flush();
170 expect(surface.onSelection).not.toHaveBeenCalled();
171 surface.down();
172 surface.up({ x: 1800, y: -10 });
173 surface.flush();
174 expect(surface.onSelection).toHaveBeenCalledWith({ x: 1800, y: -10 });
175 });
176
177 it("does not treat a consumed link or terminal mouse-reporting press as selection", () => {
178 const surface = createSelectionSurface();
179 surface.down({ consumed: true });
180 surface.up();
181 surface.change();
182 surface.flush();
183 expect(surface.onSelection).not.toHaveBeenCalled();
184 });
185
186 it.each([1, 2])(
187 "dismisses selection actions when a button is pressed after %i clicks",
188 (detail) => {
189 const surface = createSelectionSurface();
190 surface.down();
191 surface.up({ detail });
192 surface.flush();
193 surface.onSelection.mockClear();
194 surface.onDismiss.mockClear();
195
196 surface.down({ target: surface.buttonIcon });
197 surface.up();
198 surface.change();
199 surface.flush(1000);
200 expect(surface.onDismiss).toHaveBeenCalledWith("interaction");
201 expect(surface.onSelection).not.toHaveBeenCalled();
202
203 surface.down();
204 surface.change();
205 surface.up();
206 surface.flush();
207 expect(surface.onSelection).toHaveBeenCalledOnce();
208 },
209 );
210
211 it("ignores nonprimary pointers and non-left mouseup without ending a left drag", () => {
212 const surface = createSelectionSurface();
213 surface.down({ isPrimary: false });
214 surface.up();
215 surface.flush();
216 expect(surface.onSelection).not.toHaveBeenCalled();
217 surface.down();
218 surface.up({ button: 1 });
219 surface.change();
220 surface.flush();
221 expect(surface.onSelection).not.toHaveBeenCalled();
222 surface.up();
223 surface.flush();
224 expect(surface.onSelection).toHaveBeenCalledOnce();
225 });
226
227 it("uses the text fallback after keyboard selection rather than a stale pointer", () => {
228 const surface = createSelectionSurface();
229 surface.down();
230 surface.up();
231 surface.flush();
232 surface.document.dispatchEvent(event("keydown", { key: "ArrowRight", shiftKey: true }));
233 surface.change();
234 surface.flush();
235 expect(surface.onSelection).toHaveBeenLastCalledWith(null);
236 });
237
238 it("allows keyboard selection after a toolbar press suppresses compatibility mouseup", () => {
239 const surface = createSelectionSurface();
240 surface.down();
241 surface.up();
242 surface.flush();
243 surface.down({ inside: false });
244 surface.pointerUp();
245 surface.document.dispatchEvent(event("keydown", { key: "ArrowRight", shiftKey: true }));
246 surface.change();
247 surface.flush();
248 expect(surface.onSelection).toHaveBeenLastCalledWith(null);
249 });
250
251 it("waits for mouseup's click count even if selectionchange lands after pointerup", () => {
252 const surface = createSelectionSurface();
253 surface.down();
254 surface.pointerUp();
255 surface.change();
256 surface.flush();
257 expect(surface.onSelection).not.toHaveBeenCalled();
258 surface.up({ detail: 2 });
259 surface.flush(499);
260 expect(surface.onSelection).not.toHaveBeenCalled();
261 surface.flush(1);
262 expect(surface.onSelection).toHaveBeenCalledOnce();
263 });
264
265 it.each(["Escape", "contextmenu", "pointercancel", "blur", "resize", "scroll"])(
266 "%s dismisses pending actions and ignores a late selectionchange",
267 (reason) => {
268 const surface = createSelectionSurface();
269 surface.down();
270 surface.up({ detail: 2 });
271 if (reason === "Escape") surface.document.dispatchEvent(event("keydown", { key: reason }));
272 else if (reason === "contextmenu" || reason === "scroll")
273 surface.element.dispatchEvent(event(reason));
274 else surface.view.dispatchEvent(event(reason));
275 surface.change();
276 surface.flush(1000);
277 expect(surface.onSelection).not.toHaveBeenCalled();
278 surface.down();
279 surface.up();
280 surface.flush();
281 expect(surface.onSelection).toHaveBeenCalledOnce();
282 },
283 );
284
285 it("keeps an autoscrolling drag eligible to open on release", () => {
286 const surface = createSelectionSurface();
287 surface.down();
288 surface.element.dispatchEvent(event("scroll"));
289 surface.change();
290 surface.flush(1000);
291 expect(surface.onSelection).not.toHaveBeenCalled();
292 surface.up();
293 surface.flush();
294 expect(surface.onSelection).toHaveBeenCalledOnce();
295 });
296
297 it("cancels a queued animation frame when a new press supersedes the release", () => {
298 const surface = createSelectionSurface();
299 surface.down();
300 surface.up();
301 vi.advanceTimersByTime(0);
302 expect(surface.actions.pending).toBe(true);
303 surface.down();
304 surface.flushFrame();
305 expect(surface.onSelection).not.toHaveBeenCalled();
306 surface.up();
307 surface.flush();
308 expect(surface.onSelection).toHaveBeenCalledOnce();
309 });
310
311 it("teardown cancels deferred work and detaches gesture listeners", () => {
312 const surface = createSelectionSurface();
313 surface.down();
314 surface.up();
315 vi.advanceTimersByTime(0);
316 surface.actions.dispose();
317 surface.down();
318 surface.up();
319 surface.change();
320 surface.flush(1000);
321 expect(surface.onSelection).not.toHaveBeenCalled();
322 });
323});
324
325describe("interactive selection actions", () => {
326 it("keeps the captured selection while clicking, typing, and selecting in a comment field", () => {
327 const surface = createSelectionSurface({ interactiveActions: true });
328 surface.down();
329 surface.up();
330 surface.flush();
331 surface.onDismiss.mockClear();
332 surface.down({ target: surface.field });
333 surface.focus(surface.field);
334 surface.change();
335 surface.up();
336 surface.key("a", surface.field);
337 surface.change();
338 surface.key("ArrowLeft", surface.field);
339 surface.change();
340 surface.flush(1000);
341 expect(surface.onDismiss).not.toHaveBeenCalled();
342 expect(surface.onSelection).toHaveBeenCalledOnce();
343 });
344
345 it("cancels pending source reads when keyboard focus enters the actions", () => {
346 const surface = createSelectionSurface({ interactiveActions: true });
347 surface.key("Tab");
348 surface.change();
349 vi.advanceTimersByTime(0);
350 expect(surface.actions.pending).toBe(true);
351 surface.focus(surface.field);
352 surface.change();
353 surface.flushFrame();
354 expect(surface.onSelection).not.toHaveBeenCalled();
355 surface.focus(surface.actionElement);
356 expect(surface.onDismiss).not.toHaveBeenCalled();
357 });
358
359 it.each(["outside press", "outside focus", "Escape", "scroll"])(
360 "%s dismisses an interactive popover without recapturing its text",
361 (reason) => {
362 const surface = createSelectionSurface({ interactiveActions: true });
363 surface.focus(surface.field);
364 if (reason === "outside press") surface.down({ inside: false });
365 else if (reason === "outside focus") surface.focus(surface.element);
366 else if (reason === "Escape") surface.key("Escape", surface.field);
367 else surface.element.dispatchEvent(event("scroll"));
368 surface.change();
369 surface.flush(1000);
370 expect(surface.onDismiss).toHaveBeenCalledWith("cancel");
371 expect(surface.onSelection).not.toHaveBeenCalled();
372 },
373 );
374
375 it("allows a new source gesture to replace a comment selection", () => {
376 const surface = createSelectionSurface({ interactiveActions: true });
377 surface.focus(surface.field);
378 surface.down();
379 surface.focus(surface.element);
380 surface.change();
381 surface.flush();
382 expect(surface.onSelection).not.toHaveBeenCalled();
383 expect(surface.onDismiss).toHaveBeenCalledWith("interaction");
384 surface.up({ x: 520, y: 320 });
385 surface.flush();
386 expect(surface.onSelection).toHaveBeenCalledWith({ x: 520, y: 320 });
387 });
388});
389
390describe("selection action positioning", () => {
391 const bounds = { left: 100, top: 50, width: 500, height: 220 };
392 const viewport = { width: 1024, height: 768 };
393 const selectionRect = { right: 260, bottom: 140 };
394
395 it("prefers the cursor release over the selection's bounding box", () => {
396 expect(
397 resolveSelectionActionPosition({
398 bounds,
399 selectionRect,
400 pointer: { x: 520, y: 200 },
401 viewport,
402 }),
403 ).toEqual({ x: 520, y: 200 });
404 });
405
406 it("anchors keyboard selections just below the text end", () => {
407 expect(
408 resolveSelectionActionPosition({ bounds, selectionRect, pointer: null, viewport }),
409 ).toEqual({ x: 260, y: 144 });
410 });
411
412 it.each([
413 [
414 { x: 720, y: 340 },
415 { x: 600, y: 270 },
416 ],
417 [
418 { x: 40, y: 20 },
419 { x: 100, y: 50 },
420 ],
421 ])("keeps an outside release %j within the surface", (pointer, expected) => {
422 expect(resolveSelectionActionPosition({ bounds, selectionRect, pointer, viewport })).toEqual(
423 expected,
424 );
425 });
426
427 it("keeps the anchor inside the browser even if the surface extends beyond it", () => {
428 expect(
429 resolveSelectionActionPosition({
430 bounds: { left: -100, top: -100, width: 2000, height: 2000 },
431 selectionRect,
432 pointer: { x: 1800, y: -50 },
433 viewport,
434 }),
435 ).toEqual({ x: 1016, y: 8 });
436 });
437});