| 1 | import type { SensorProps } from "@dnd-kit/core"; |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | import { act, createElement, StrictMode } from "react"; |
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 5 | import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; |
| 6 | |
| 7 | class TestDocument extends EventTarget { |
| 8 | hidden = false; |
| 9 | getSelection = () => ({ removeAllRanges() {} }); |
| 10 | } |
| 11 | |
| 12 | let document: TestDocument; |
| 13 | let window: EventTarget; |
| 14 | const sensors: SidebarPointerSensor[] = []; |
| 15 | |
| 16 | function 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 | |
| 26 | function 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 | |
| 50 | beforeEach(() => { |
| 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 | }); |
| 58 | afterEach(() => { |
| 59 | for (const sensor of sensors.splice(0)) sensor.cancel(); |
| 60 | vi.runAllTimers(); |
| 61 | vi.useRealTimers(); |
| 62 | vi.unstubAllGlobals(); |
| 63 | }); |
| 64 | |
| 65 | describe("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 | }); |