1import type { SensorProps } from "@dnd-kit/core";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import { act, createElement, StrictMode } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";
6
7class TestDocument extends EventTarget {
8 hidden = false;
9 getSelection = () => ({ removeAllRanges() {} });
10}
11
12let document: TestDocument;
13let window: EventTarget;
14const sensors: SidebarPointerSensor[] = [];
15
16function pointer(type: string, values: Partial<PointerEvent> = {}) {
17 return Object.assign(new Event(type, { cancelable: true }), {
18 pointerId: 1,
19 clientX: 10,
20 clientY: 10,
21 buttons: 1,
22 ...values,
23 });
24}
25
26function gesture(
27 extraOptions: Partial<ConstructorParameters<typeof SidebarPointerSensor>[0]["options"]> = {},
28) {
29 const callbacks = {
30 onStart: vi.fn(),
31 onMove: vi.fn(),
32 onEnd: vi.fn(),
33 onCancel: vi.fn(),
34 onAbort: vi.fn(),
35 onPending: vi.fn(),
36 };
37 const onFinish = vi.fn();
38 // The sensor never reads dnd-kit's layout context or active node.
39 const props = {
40 active: "thread",
41 event: pointer("pointerdown"),
42 options: { distance: 6, onAttach: vi.fn(), onFinish, ...extraOptions },
43 ...callbacks,
44 } as unknown as SensorProps<ConstructorParameters<typeof SidebarPointerSensor>[0]["options"]>;
45 const sensor = new SidebarPointerSensor(props);
46 sensors.push(sensor);
47 return { sensor, onFinish, ...callbacks };
48}
49
50beforeEach(() => {
51 vi.useFakeTimers();
52 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
53 document = new TestDocument();
54 window = Object.assign(new EventTarget(), { setTimeout });
55 vi.stubGlobal("document", document);
56 vi.stubGlobal("window", window);
57});
58afterEach(() => {
59 for (const sensor of sensors.splice(0)) sensor.cancel();
60 vi.runAllTimers();
61 vi.useRealTimers();
62 vi.unstubAllGlobals();
63});
64
65describe("sidebar pointer lifecycle", () => {
66 it("keeps a click idle and starts only after the drag threshold", () => {
67 const click = gesture();
68 document.dispatchEvent(pointer("pointermove", { clientY: 16 }));
69 expect(click.onStart).not.toHaveBeenCalled();
70 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
71 expect(click.onAbort).toHaveBeenCalledOnce();
72 expect(click.onFinish).toHaveBeenCalledOnce();
73
74 const drag = gesture();
75 document.dispatchEvent(pointer("pointermove", { clientY: 17 }));
76 expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 });
77 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
78 expect(drag.onEnd).toHaveBeenCalledOnce();
79 expect(drag.onAbort).not.toHaveBeenCalled();
80 expect(drag.onFinish).toHaveBeenCalledOnce();
81 });
82
83 it("moves to the pointer on the move that starts the drag", () => {
84 const drag = gesture();
85 document.dispatchEvent(pointer("pointermove", { clientY: 90 }));
86 expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 });
87 expect(drag.onMove).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 90 });
88 });
89
90 const interruptions = {
91 blur: () => window.dispatchEvent(new Event("blur")),
92 hidden: () => {
93 document.hidden = true;
94 document.dispatchEvent(new Event("visibilitychange"));
95 },
96 pagehide: () => window.dispatchEvent(new Event("pagehide")),
97 resize: () => window.dispatchEvent(new Event("resize")),
98 escape: () => document.dispatchEvent(Object.assign(new Event("keydown"), { code: "Escape" })),
99 pointercancel: () => document.dispatchEvent(pointer("pointercancel")),
100 "missed release": () =>
101 document.dispatchEvent(pointer("pointermove", { buttons: 0, clientY: 100 })),
102 };
103 for (const [name, interrupt] of Object.entries(interruptions)) {
104 it.each([false, true])(`cancels on ${name}, started=%s, and ignores late events`, (started) => {
105 const drag = gesture();
106 if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
107 interrupt();
108 document.dispatchEvent(pointer("pointermove", { clientY: 100 }));
109 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
110 drag.sensor.cancel();
111 expect(drag.onCancel).toHaveBeenCalledOnce();
112 expect(drag.onEnd).not.toHaveBeenCalled();
113 expect(drag.onFinish).toHaveBeenCalledOnce();
114 expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0);
115 expect(drag.onAbort).toHaveBeenCalledTimes(started ? 0 : 1);
116
117 document.hidden = false;
118 const next = gesture();
119 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
120 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
121 expect(next.onStart).toHaveBeenCalledOnce();
122 expect(next.onEnd).toHaveBeenCalledOnce();
123 });
124 }
125
126 it("hands claimed moves and drops to the context handlers and cancels the sort", () => {
127 const onMove = vi.fn((point: { x: number; y: number }) => point.x > 50);
128 const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50);
129 const drag = gesture({ onMove, onDrop });
130 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
131 document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 }));
132 expect(drag.onMove).toHaveBeenNthCalledWith(1, { x: 10, y: 20 });
133 expect(drag.onMove).toHaveBeenNthCalledWith(2, { x: 30, y: 30 });
134 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 }));
135 // Outside the list the sort no longer sees the pointer.
136 expect(drag.onMove).toHaveBeenCalledTimes(2);
137 document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 }));
138 expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 90, y: 30 });
139 expect(drag.onCancel).toHaveBeenCalledOnce();
140 expect(drag.onEnd).not.toHaveBeenCalled();
141 expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(true);
142
143 const inside = gesture({ onMove, onDrop });
144 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
145 document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 10, clientY: 20 }));
146 expect(inside.onEnd).toHaveBeenCalledOnce();
147 expect(inside.onCancel).not.toHaveBeenCalled();
148 });
149
150 it("drops context when a zero-button move precedes pointerup", () => {
151 const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50);
152 const drag = gesture({ onMove: (point) => point.x > 50, onDrop });
153 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
154 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 }));
155
156 // Native desktop releases can report the released button on the final move.
157 document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 94, clientY: 32 }));
158 document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 94, clientY: 32 }));
159 document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 100, clientY: 40 }));
160
161 expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 94, y: 32 });
162 expect(drag.onCancel).toHaveBeenCalledOnce();
163 expect(drag.onEnd).not.toHaveBeenCalled();
164 expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(true);
165 });
166
167 it("cancels a missed sidebar release without committing a reorder", () => {
168 const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50);
169 const drag = gesture({ onMove: (point) => point.x > 50, onDrop });
170 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 }));
171 document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 30, clientY: 40 }));
172 document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 30, clientY: 40 }));
173
174 expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 40 });
175 expect(drag.onCancel).toHaveBeenCalledOnce();
176 expect(drag.onEnd).not.toHaveBeenCalled();
177 expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(true);
178 });
179
180 it("does not drop context when the button is released before activation", () => {
181 const onDrop = vi.fn(() => true);
182 const drag = gesture({ onDrop });
183 document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 90, clientY: 30 }));
184 document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 }));
185
186 expect(onDrop).not.toHaveBeenCalled();
187 expect(drag.onStart).not.toHaveBeenCalled();
188 expect(drag.onAbort).toHaveBeenCalledOnce();
189 expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(false);
190 });
191
192 it("resumes reordering when a context drag returns to the sidebar", () => {
193 const drag = gesture({
194 onMove: (point) => point.x > 50,
195 onDrop: (point) => point.x > 50,
196 });
197 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
198 document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 30 }));
199 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 100 }));
200 document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 200 }));
201 expect(drag.onMove).toHaveBeenCalledTimes(2);
202 expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 30 });
203
204 document.dispatchEvent(pointer("pointermove", { clientX: 30, clientY: 60 }));
205 expect(drag.onMove).toHaveBeenLastCalledWith({ x: 30, y: 60 });
206 document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 30, clientY: 60 }));
207 expect(drag.onEnd).toHaveBeenCalledOnce();
208 expect(drag.onCancel).not.toHaveBeenCalled();
209 });
210
211 it.each([false, true])("suppresses a cancelled release click, started=%s", (started) => {
212 const drag = gesture();
213 if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
214 drag.sensor.cancel();
215 vi.advanceTimersByTime(1000);
216 const releaseClick = new Event("click");
217 const releasePropagation = vi.spyOn(releaseClick, "stopPropagation");
218 document.dispatchEvent(releaseClick);
219 expect(releasePropagation).toHaveBeenCalledOnce();
220 document.dispatchEvent(pointer("pointerdown"));
221 const nextClick = new Event("click");
222 const nextPropagation = vi.spyOn(nextClick, "stopPropagation");
223 document.dispatchEvent(nextClick);
224 expect(nextPropagation).not.toHaveBeenCalled();
225 });
226
227 it.each([false, true])("allows a fresh click after an outside release, started=%s", (started) => {
228 const drag = gesture();
229 if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
230 drag.sensor.cancel();
231 document.dispatchEvent(pointer("pointerdown"));
232 const click = new Event("click");
233 const propagation = vi.spyOn(click, "stopPropagation");
234 document.dispatchEvent(click);
235 expect(propagation).not.toHaveBeenCalled();
236 });
237
238 it("does not move after cancellation during activation", () => {
239 const drag = gesture();
240 drag.onStart.mockImplementation(() => drag.sensor.cancel());
241 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
242 expect(drag.onCancel).toHaveBeenCalledOnce();
243 expect(drag.onMove).not.toHaveBeenCalled();
244 expect(drag.onFinish).toHaveBeenCalledOnce();
245 });
246
247 it("ignores events from other pointers", () => {
248 const drag = gesture();
249 document.dispatchEvent(pointer("pointermove", { pointerId: 2, buttons: 0, clientY: 100 }));
250 document.dispatchEvent(pointer("pointercancel", { pointerId: 2 }));
251 document.dispatchEvent(pointer("pointerup", { pointerId: 2 }));
252 expect(drag.onStart).not.toHaveBeenCalled();
253 expect(drag.onFinish).not.toHaveBeenCalled();
254 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
255 expect(drag.onStart).toHaveBeenCalledOnce();
256 });
257
258 it.each([false, true])("cancels when search unmounts the list, started=%s", (started) => {
259 let renderer: ReactTestRenderer;
260 let drag: ReturnType<typeof gesture> | undefined;
261 act(() => {
262 renderer = create(
263 createElement(
264 StrictMode,
265 null,
266 createElement(SidebarDragLifecycle, {
267 onUnmount: () => drag?.sensor.cancel(),
268 }),
269 ),
270 );
271 });
272 drag = gesture();
273 if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
274 act(() => renderer.unmount());
275 document.dispatchEvent(pointer("pointermove", { clientY: 100 }));
276 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
277 expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0);
278 expect(drag.onCancel).toHaveBeenCalledOnce();
279 expect(drag.onEnd).not.toHaveBeenCalled();
280 expect(drag.onFinish).toHaveBeenCalledOnce();
281 });
282
283 it("detaches a cancelled sensor before a replacement gesture starts", () => {
284 const previous = gesture();
285 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
286 previous.sensor.cancel();
287 const next = gesture();
288 document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
289 document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
290 expect(previous.onCancel).toHaveBeenCalledOnce();
291 expect(previous.onEnd).not.toHaveBeenCalled();
292 expect(next.onEnd).toHaveBeenCalledOnce();
293 });
294});