import type { SensorProps } from "@dnd-kit/core";import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";import { act, createElement, StrictMode } from "react";import { create, type ReactTestRenderer } from "react-test-renderer";import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; class TestDocument extends EventTarget { hidden = false; getSelection = () => ({ removeAllRanges() {} });} let document: TestDocument;let window: EventTarget;const sensors: SidebarPointerSensor[] = []; function pointer(type: string, values: Partial<PointerEvent> = {}) { return Object.assign(new Event(type, { cancelable: true }), { pointerId: 1, clientX: 10, clientY: 10, buttons: 1, ...values, });} function gesture() { const callbacks = { onStart: vi.fn(), onMove: vi.fn(), onEnd: vi.fn(), onCancel: vi.fn(), onAbort: vi.fn(), onPending: vi.fn(), }; const onFinish = vi.fn(); // The sensor never reads dnd-kit's layout context or active node. const props = { active: "thread", event: pointer("pointerdown"), options: { distance: 6, onAttach: vi.fn(), onFinish }, ...callbacks, } as unknown as SensorProps<ConstructorParameters<typeof SidebarPointerSensor>[0]["options"]>; const sensor = new SidebarPointerSensor(props); sensors.push(sensor); return { sensor, onFinish, ...callbacks };} beforeEach(() => { vi.useFakeTimers(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); document = new TestDocument(); window = Object.assign(new EventTarget(), { setTimeout }); vi.stubGlobal("document", document); vi.stubGlobal("window", window);});afterEach(() => { for (const sensor of sensors.splice(0)) sensor.cancel(); vi.runAllTimers(); vi.useRealTimers(); vi.unstubAllGlobals();}); describe("sidebar pointer lifecycle", () => { it("keeps a click idle and starts only after the drag threshold", () => { const click = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 16 })); expect(click.onStart).not.toHaveBeenCalled(); document.dispatchEvent(pointer("pointerup", { buttons: 0 })); expect(click.onAbort).toHaveBeenCalledOnce(); expect(click.onFinish).toHaveBeenCalledOnce(); const drag = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 17 })); expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 }); document.dispatchEvent(pointer("pointerup", { buttons: 0 })); expect(drag.onEnd).toHaveBeenCalledOnce(); expect(drag.onAbort).not.toHaveBeenCalled(); expect(drag.onFinish).toHaveBeenCalledOnce(); }); const interruptions = { blur: () => window.dispatchEvent(new Event("blur")), hidden: () => { document.hidden = true; document.dispatchEvent(new Event("visibilitychange")); }, pagehide: () => window.dispatchEvent(new Event("pagehide")), resize: () => window.dispatchEvent(new Event("resize")), escape: () => document.dispatchEvent(Object.assign(new Event("keydown"), { code: "Escape" })), pointercancel: () => document.dispatchEvent(pointer("pointercancel")), "missed release": () => document.dispatchEvent(pointer("pointermove", { buttons: 0, clientY: 100 })), }; for (const [name, interrupt] of Object.entries(interruptions)) { it.each([false, true])(`cancels on ${name}, started=%s, and ignores late events`, (started) => { const drag = gesture(); if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); interrupt(); document.dispatchEvent(pointer("pointermove", { clientY: 100 })); document.dispatchEvent(pointer("pointerup", { buttons: 0 })); drag.sensor.cancel(); expect(drag.onCancel).toHaveBeenCalledOnce(); expect(drag.onEnd).not.toHaveBeenCalled(); expect(drag.onFinish).toHaveBeenCalledOnce(); expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0); expect(drag.onAbort).toHaveBeenCalledTimes(started ? 0 : 1); document.hidden = false; const next = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); document.dispatchEvent(pointer("pointerup", { buttons: 0 })); expect(next.onStart).toHaveBeenCalledOnce(); expect(next.onEnd).toHaveBeenCalledOnce(); }); } it("suppresses a delayed release click after cancellation, then accepts the next click", () => { const drag = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); drag.sensor.cancel(); vi.advanceTimersByTime(1000); const releaseClick = new Event("click"); const releasePropagation = vi.spyOn(releaseClick, "stopPropagation"); document.dispatchEvent(releaseClick); expect(releasePropagation).toHaveBeenCalledOnce(); document.dispatchEvent(pointer("pointerdown")); const nextClick = new Event("click"); const nextPropagation = vi.spyOn(nextClick, "stopPropagation"); document.dispatchEvent(nextClick); expect(nextPropagation).not.toHaveBeenCalled(); }); it("allows the next click when the cancelled release happened outside the document", () => { const drag = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); drag.sensor.cancel(); document.dispatchEvent(pointer("pointerdown")); const click = new Event("click"); const propagation = vi.spyOn(click, "stopPropagation"); document.dispatchEvent(click); expect(propagation).not.toHaveBeenCalled(); }); it("does not move after cancellation during activation", () => { const drag = gesture(); drag.onStart.mockImplementation(() => drag.sensor.cancel()); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); expect(drag.onCancel).toHaveBeenCalledOnce(); expect(drag.onMove).not.toHaveBeenCalled(); expect(drag.onFinish).toHaveBeenCalledOnce(); }); it("ignores events from other pointers", () => { const drag = gesture(); document.dispatchEvent(pointer("pointermove", { pointerId: 2, buttons: 0, clientY: 100 })); document.dispatchEvent(pointer("pointercancel", { pointerId: 2 })); document.dispatchEvent(pointer("pointerup", { pointerId: 2 })); expect(drag.onStart).not.toHaveBeenCalled(); expect(drag.onFinish).not.toHaveBeenCalled(); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); expect(drag.onStart).toHaveBeenCalledOnce(); }); it.each([false, true])("cancels when search unmounts the list, started=%s", (started) => { let renderer: ReactTestRenderer; let drag: ReturnType<typeof gesture> | undefined; act(() => { renderer = create( createElement( StrictMode, null, createElement(SidebarDragLifecycle, { onUnmount: () => drag?.sensor.cancel(), }), ), ); }); drag = gesture(); if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 })); act(() => renderer.unmount()); document.dispatchEvent(pointer("pointermove", { clientY: 100 })); document.dispatchEvent(pointer("pointerup", { buttons: 0 })); expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0); expect(drag.onCancel).toHaveBeenCalledOnce(); expect(drag.onEnd).not.toHaveBeenCalled(); expect(drag.onFinish).toHaveBeenCalledOnce(); }); it("detaches a cancelled sensor before a replacement gesture starts", () => { const previous = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); previous.sensor.cancel(); const next = gesture(); document.dispatchEvent(pointer("pointermove", { clientY: 20 })); document.dispatchEvent(pointer("pointerup", { buttons: 0 })); expect(previous.onCancel).toHaveBeenCalledOnce(); expect(previous.onEnd).not.toHaveBeenCalled(); expect(next.onEnd).toHaveBeenCalledOnce(); });});
import type { SensorProps } from "@dnd-kit/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import { act, createElement, StrictMode } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";
class TestDocument extends EventTarget {
hidden = false;
getSelection = () => ({ removeAllRanges() {} });
}
let document: TestDocument;
let window: EventTarget;
const sensors: SidebarPointerSensor[] = [];
function pointer(type: string, values: Partial<PointerEvent> = {}) {
return Object.assign(new Event(type, { cancelable: true }), {
pointerId: 1,
clientX: 10,
clientY: 10,
buttons: 1,
...values,
});
}
function gesture() {
const callbacks = {
onStart: vi.fn(),
onMove: vi.fn(),
onEnd: vi.fn(),
onCancel: vi.fn(),
onAbort: vi.fn(),
onPending: vi.fn(),
};
const onFinish = vi.fn();
// The sensor never reads dnd-kit's layout context or active node.
const props = {
active: "thread",
event: pointer("pointerdown"),
options: { distance: 6, onAttach: vi.fn(), onFinish },
...callbacks,
} as unknown as SensorProps<ConstructorParameters<typeof SidebarPointerSensor>[0]["options"]>;
const sensor = new SidebarPointerSensor(props);
sensors.push(sensor);
return { sensor, onFinish, ...callbacks };
}
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
document = new TestDocument();
window = Object.assign(new EventTarget(), { setTimeout });
vi.stubGlobal("document", document);
vi.stubGlobal("window", window);
});
afterEach(() => {
for (const sensor of sensors.splice(0)) sensor.cancel();
vi.runAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("sidebar pointer lifecycle", () => {
it("keeps a click idle and starts only after the drag threshold", () => {
const click = gesture();
document.dispatchEvent(pointer("pointermove", { clientY: 16 }));
expect(click.onStart).not.toHaveBeenCalled();
document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
expect(click.onAbort).toHaveBeenCalledOnce();
expect(click.onFinish).toHaveBeenCalledOnce();
const drag = gesture();
document.dispatchEvent(pointer("pointermove", { clientY: 17 }));
expect(drag.onStart).toHaveBeenCalledExactlyOnceWith({ x: 10, y: 10 });
document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
expect(drag.onEnd).toHaveBeenCalledOnce();
expect(drag.onAbort).not.toHaveBeenCalled();
expect(drag.onFinish).toHaveBeenCalledOnce();
});
const interruptions = {
blur: () => window.dispatchEvent(new Event("blur")),
hidden: () => {
document.hidden = true;
document.dispatchEvent(new Event("visibilitychange"));
},
pagehide: () => window.dispatchEvent(new Event("pagehide")),
resize: () => window.dispatchEvent(new Event("resize")),
escape: () => document.dispatchEvent(Object.assign(new Event("keydown"), { code: "Escape" })),
pointercancel: () => document.dispatchEvent(pointer("pointercancel")),
"missed release": () =>
document.dispatchEvent(pointer("pointermove", { buttons: 0, clientY: 100 })),
};
for (const [name, interrupt] of Object.entries(interruptions)) {
it.each([false, true])(`cancels on ${name}, started=%s, and ignores late events`, (started) => {
const drag = gesture();
if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
interrupt();
document.dispatchEvent(pointer("pointermove", { clientY: 100 }));
document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
drag.sensor.cancel();
expect(drag.onCancel).toHaveBeenCalledOnce();
expect(drag.onEnd).not.toHaveBeenCalled();
expect(drag.onFinish).toHaveBeenCalledOnce();
expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0);
expect(drag.onAbort).toHaveBeenCalledTimes(started ? 0 : 1);
document.hidden = false;
const next = gesture();
document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
expect(next.onStart).toHaveBeenCalledOnce();
expect(next.onEnd).toHaveBeenCalledOnce();
});
}
it("suppresses a delayed release click after cancellation, then accepts the next click", () => {
const drag = gesture();
document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
drag.sensor.cancel();
vi.advanceTimersByTime(1000);
const releaseClick = new Event("click");
const releasePropagation = vi.spyOn(releaseClick, "stopPropagation");
document.dispatchEvent(releaseClick);
expect(releasePropagation).toHaveBeenCalledOnce();
document.dispatchEvent(pointer("pointerdown"));
const nextClick = new Event("click");
const nextPropagation = vi.spyOn(nextClick, "stopPropagation");
document.dispatchEvent(nextClick);
expect(nextPropagation).not.toHaveBeenCalled();
});
it("allows the next click when the cancelled release happened outside the document", () => {
const drag = gesture();
document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
drag.sensor.cancel();
document.dispatchEvent(pointer("pointerdown"));
const click = new Event("click");
const propagation = vi.spyOn(click, "stopPropagation");
document.dispatchEvent(click);
expect(propagation).not.toHaveBeenCalled();
});
it("does not move after cancellation during activation", () => {
const drag = gesture();
drag.onStart.mockImplementation(() => drag.sensor.cancel());
document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
expect(drag.onCancel).toHaveBeenCalledOnce();
expect(drag.onMove).not.toHaveBeenCalled();
expect(drag.onFinish).toHaveBeenCalledOnce();
});
it("ignores events from other pointers", () => {
const drag = gesture();
document.dispatchEvent(pointer("pointermove", { pointerId: 2, buttons: 0, clientY: 100 }));
document.dispatchEvent(pointer("pointercancel", { pointerId: 2 }));
document.dispatchEvent(pointer("pointerup", { pointerId: 2 }));
expect(drag.onStart).not.toHaveBeenCalled();
expect(drag.onFinish).not.toHaveBeenCalled();
document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
expect(drag.onStart).toHaveBeenCalledOnce();
});
it.each([false, true])("cancels when search unmounts the list, started=%s", (started) => {
let renderer: ReactTestRenderer;
let drag: ReturnType<typeof gesture> | undefined;
act(() => {
renderer = create(
createElement(
StrictMode,
null,
createElement(SidebarDragLifecycle, {
onUnmount: () => drag?.sensor.cancel(),
}),
),
);
});
drag = gesture();
if (started) document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
act(() => renderer.unmount());
document.dispatchEvent(pointer("pointermove", { clientY: 100 }));
document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
expect(drag.onStart).toHaveBeenCalledTimes(started ? 1 : 0);
expect(drag.onCancel).toHaveBeenCalledOnce();
expect(drag.onEnd).not.toHaveBeenCalled();
expect(drag.onFinish).toHaveBeenCalledOnce();
});
it("detaches a cancelled sensor before a replacement gesture starts", () => {
const previous = gesture();
document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
previous.sensor.cancel();
const next = gesture();
document.dispatchEvent(pointer("pointermove", { clientY: 20 }));
document.dispatchEvent(pointer("pointerup", { buttons: 0 }));
expect(previous.onCancel).toHaveBeenCalledOnce();
expect(previous.onEnd).not.toHaveBeenCalled();
expect(next.onEnd).toHaveBeenCalledOnce();
});
});