1import { act, useLayoutEffect, useState, type KeyboardEvent, type PointerEvent } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { useResizeDrag } from "./useResizeDrag";
6import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth";
7
8let renderer: ReactTestRenderer;
9let result: ReturnType<typeof useResizableWidth>;
10let captured = false;
11const hostStyle = new Map<string, string>();
12const target = {
13 parentElement: {
14 style: {
15 setProperty: (property: string, value: string) => hostStyle.set(property, value),
16 removeProperty: (property: string) => hostStyle.delete(property),
17 },
18 },
19 setPointerCapture: () => {
20 captured = true;
21 },
22 hasPointerCapture: () => captured,
23 releasePointerCapture: () => {
24 captured = false;
25 },
26};
27const style = {
28 cursor: "",
29 userSelect: "",
30 removeProperty(property: string) {
31 if (property === "cursor") this.cursor = "";
32 if (property === "user-select") this.userSelect = "";
33 },
34};
35const savedWidths = new Map<string, string>();
36const setItem = vi.fn((key: string, value: string) => savedWidths.set(key, value));
37const cancelAnimationFrame = vi.fn();
38let events: EventTarget;
39let frame: FrameRequestCallback | undefined;
40
41function pointer(clientX = 100) {
42 return {
43 button: 0,
44 pointerId: 1,
45 clientX,
46 currentTarget: target,
47 target,
48 preventDefault() {},
49 stopPropagation() {},
50 } as unknown as PointerEvent<HTMLElement>;
51}
52
53function Panel({
54 edge = "left",
55 maxWidth = 800,
56 storageKey = "test-panel-width",
57}: {
58 edge?: "left" | "right";
59 maxWidth?: number;
60 storageKey?: string;
61}) {
62 const resize = useResizableWidth({
63 storageKey,
64 defaultWidth: 400,
65 minWidth: 200,
66 maxWidth,
67 edge,
68 });
69 useLayoutEffect(() => {
70 result = resize;
71 });
72 return null;
73}
74
75let verticalHandlers: ReturnType<typeof useResizeDrag<HTMLElement>>;
76let committedHeight = 400;
77
78function VerticalPanel() {
79 const [height, setHeight] = useState(400);
80 const handlers = useResizeDrag<HTMLElement>(() => ({
81 size: height,
82 edge: "top",
83 resize(size) {
84 const next = Math.max(180, Math.min(600, size));
85 hostStyle.set("height", `${next}px`);
86 return next;
87 },
88 finish(size) {
89 setHeight(size);
90 committedHeight = size;
91 },
92 cleanup(committed) {
93 if (!committed) hostStyle.set("height", `${height}px`);
94 },
95 }));
96 useLayoutEffect(() => {
97 verticalHandlers = handlers;
98 });
99 return null;
100}
101
102beforeEach(async () => {
103 savedWidths.clear();
104 hostStyle.clear();
105 captured = false;
106 frame = undefined;
107 style.cursor = "";
108 style.userSelect = "";
109 events = new EventTarget();
110 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
111 vi.stubGlobal("window", {
112 addEventListener: events.addEventListener.bind(events),
113 removeEventListener: events.removeEventListener.bind(events),
114 localStorage: { getItem: (key: string) => savedWidths.get(key) ?? null, setItem },
115 });
116 vi.stubGlobal("document", { body: { style } });
117 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
118 frame = callback;
119 return 42;
120 });
121 vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame);
122 await act(() => {
123 renderer = create(<Panel />);
124 });
125});
126
127afterEach(async () => {
128 await act(() => renderer.unmount());
129 vi.unstubAllGlobals();
130 vi.clearAllMocks();
131});
132
133describe("panel resize cleanup", () => {
134 it.each(["unmount", "lost capture", "blur", "cancel"])(
135 "clears the cursor and pending resize after %s",
136 async (reason) => {
137 await act(() => {
138 result.handlers.onPointerDown(pointer());
139 result.handlers.onPointerMove(pointer(50));
140 });
141 await act(() => frame?.(0));
142 expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("450px");
143 expect(hostStyle.get("transition-property")).toBe("none");
144 expect(result.width).toBe(400);
145 // Queue another move to check that interruption cancels pending work too.
146 await act(() => result.handlers.onPointerMove(pointer(25)));
147 expect(style.cursor).toBe("col-resize");
148 expect(style.userSelect).toBe("none");
149 await act(() => {
150 if (reason === "unmount") renderer.unmount();
151 if (reason === "lost capture") result.handlers.onLostPointerCapture(pointer());
152 if (reason === "blur") events.dispatchEvent(new Event("blur"));
153 if (reason === "cancel") result.handlers.onPointerCancel(pointer());
154 });
155 expect(style.cursor).toBe("");
156 expect(style.userSelect).toBe("");
157 expect(captured).toBe(false);
158 expect(hostStyle.has("transition-property")).toBe(false);
159 expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
160 if (reason === "unmount") {
161 expect(setItem).not.toHaveBeenCalled();
162 } else {
163 expect(result.width).toBe(475);
164 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "475");
165 }
166 },
167 );
168
169 it.each(["left", "right"] as const)(
170 "uses the release position for a fast %s-edge drag",
171 async (edge) => {
172 await act(() => renderer.update(<Panel edge={edge} />));
173 await act(() => {
174 result.handlers.onPointerDown(pointer());
175 result.handlers.onPointerMove(pointer(edge === "left" ? 50 : 150));
176 result.handlers.onPointerUp(pointer(edge === "left" ? 25 : 175));
177 });
178 expect(result.width).toBe(475);
179 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "475");
180 expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
181 },
182 );
183
184 it.each([
185 [800, 450],
186 [450, 800],
187 ])("uses updated bounds during a drag from max %s to %s", async (initialMax, nextMax) => {
188 await act(() => renderer.update(<Panel maxWidth={initialMax} />));
189 await act(() => {
190 result.handlers.onPointerDown(pointer());
191 result.handlers.onPointerMove(pointer(-200));
192 });
193 await act(() => frame?.(0));
194 expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe(`${Math.min(700, initialMax)}px`);
195 await act(() => renderer.update(<Panel maxWidth={nextMax} />));
196 await act(() => result.handlers.onPointerMove(pointer(-250)));
197 await act(() => frame?.(0));
198 expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe(`${Math.min(750, nextMax)}px`);
199 await act(() => result.handlers.onPointerUp(pointer(-300)));
200 expect(result.width).toBe(nextMax);
201 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", String(nextMax));
202 });
203
204 it("handles release before any move event", async () => {
205 await act(() => {
206 result.handlers.onPointerDown(pointer());
207 result.handlers.onPointerUp(pointer(25));
208 });
209 expect(result.width).toBe(475);
210 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "475");
211 });
212
213 it("clamps the release position to the panel bounds", async () => {
214 await act(() => {
215 result.handlers.onPointerDown(pointer());
216 result.handlers.onPointerUp(pointer(-1000));
217 });
218 expect(result.width).toBe(800);
219 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "800");
220 });
221
222 it("saves the final width when release is followed by lost capture", async () => {
223 await act(() => {
224 result.handlers.onPointerDown(pointer());
225 result.handlers.onPointerMove(pointer(50));
226 result.handlers.onPointerUp(pointer(50));
227 result.handlers.onLostPointerCapture(pointer(50));
228 });
229 expect(result.width).toBe(450);
230 expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "450");
231 expect(style.cursor).toBe("");
232 expect(captured).toBe(false);
233 });
234});
235
236describe("vertical drawer resize lifecycle", () => {
237 it("uses the final vertical release position and ignores horizontal movement", async () => {
238 committedHeight = 400;
239 await act(() => renderer.update(<VerticalPanel />));
240 await act(() => {
241 verticalHandlers.onPointerDown({ ...pointer(), clientY: 100 });
242 verticalHandlers.onPointerMove({ ...pointer(-1000), clientY: 50 });
243 verticalHandlers.onPointerUp({ ...pointer(1000), clientY: 25 });
244 });
245 expect(committedHeight).toBe(475);
246 expect(hostStyle.get("height")).toBe("475px");
247 expect(style.cursor).toBe("");
248 expect(captured).toBe(false);
249 });
250
251 it("abandons an unfinished vertical drag on unmount without committing", async () => {
252 committedHeight = 400;
253 await act(() => renderer.update(<VerticalPanel />));
254 await act(() => {
255 verticalHandlers.onPointerDown({ ...pointer(), clientY: 100 });
256 verticalHandlers.onPointerMove({ ...pointer(), clientY: -1000 });
257 });
258 await act(() => frame?.(0));
259 expect(hostStyle.get("height")).toBe("600px");
260 expect(style.cursor).toBe("row-resize");
261 await act(() => renderer.unmount());
262 expect(committedHeight).toBe(400);
263 expect(hostStyle.get("height")).toBe("400px");
264 expect(style.userSelect).toBe("");
265 expect(captured).toBe(false);
266 });
267});
268
269describe("resize acquisition and keyboard", () => {
270 it("does not acquire a drag that started in selectable descendant content", async () => {
271 const event = { ...pointer(), target: {} } as PointerEvent<HTMLElement>;
272 await act(() => {
273 result.handlers.onPointerDown(event);
274 result.handlers.onPointerMove(pointer(50));
275 result.handlers.onPointerUp(pointer(50));
276 });
277 expect(captured).toBe(false);
278 expect(result.width).toBe(400);
279 expect(style.userSelect).toBe("");
280 expect(setItem).not.toHaveBeenCalled();
281 });
282
283 it("ignores a secondary button and another pointer during an active drag", async () => {
284 await act(() => result.handlers.onPointerDown({ ...pointer(), button: 2 }));
285 expect(captured).toBe(false);
286 await act(() => {
287 result.handlers.onPointerDown(pointer());
288 result.handlers.onPointerMove({ ...pointer(-500), pointerId: 2 });
289 result.handlers.onPointerUp({ ...pointer(-500), pointerId: 2 });
290 });
291 expect(result.width).toBe(400);
292 expect(captured).toBe(true);
293 await act(() => result.handlers.onPointerUp(pointer(50)));
294 expect(result.width).toBe(450);
295 });
296
297 it("resizes from keyboard in edge direction, clamps and persists across reload", async () => {
298 const key = (key: string, shiftKey = false) =>
299 ({ key, shiftKey, preventDefault() {} }) as KeyboardEvent<HTMLElement>;
300 await act(() => result.handlers.onKeyDown(key("ArrowLeft")));
301 expect(result.width).toBe(410);
302 await act(() => result.handlers.onKeyDown(key("ArrowLeft", true)));
303 expect(result.width).toBe(450);
304 await act(() => result.handlers.onKeyDown(key("Home")));
305 expect(result.width).toBe(200);
306 await act(() => result.handlers.onKeyDown(key("ArrowRight")));
307 expect(result.width).toBe(200);
308 await act(() => renderer.unmount());
309 await act(() => {
310 renderer = create(<Panel />);
311 });
312 expect(result.width).toBe(200);
313 expect(captured).toBe(false);
314 });
315});
316
317describe("panel width storage changes", () => {
318 it("restores separate thread widths without remounting and retains them after reload", async () => {
319 await act(() => {
320 result.handlers.onPointerDown(pointer());
321 result.handlers.onPointerMove(pointer(50));
322 result.handlers.onPointerUp(pointer(50));
323 });
324 expect(result.width).toBe(450);
325 await act(() => renderer.update(<Panel storageKey="thread-b" />));
326 expect(result.width).toBe(400);
327 await act(() => {
328 result.handlers.onPointerDown(pointer());
329 result.handlers.onPointerMove(pointer(-100));
330 result.handlers.onPointerUp(pointer(-100));
331 });
332 expect(result.width).toBe(600);
333 await act(() => renderer.update(<Panel />));
334 expect(result.width).toBe(450);
335 await act(() => renderer.unmount());
336 await act(() => {
337 renderer = create(<Panel storageKey="thread-b" />);
338 });
339 expect(result.width).toBe(600);
340 });
341
342 it("cancels an unfinished drag on a thread switch without saving it to either thread", async () => {
343 savedWidths.set("thread-b", "650");
344 await act(() => {
345 result.handlers.onPointerDown(pointer());
346 result.handlers.onPointerMove(pointer(50));
347 });
348 await act(() => frame?.(0));
349 expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("450px");
350 await act(() => result.handlers.onPointerMove(pointer(25)));
351 await act(() => renderer.update(<Panel storageKey="thread-b" />));
352 expect(result.width).toBe(650);
353 expect(hostStyle.get(RESIZABLE_WIDTH_PROPERTY)).toBe("650px");
354 expect(captured).toBe(false);
355 expect(style.cursor).toBe("");
356 await act(() => {
357 frame?.(0);
358 result.handlers.onPointerUp(pointer(25));
359 });
360 expect(result.width).toBe(650);
361 expect(setItem).not.toHaveBeenCalled();
362 await act(() => renderer.update(<Panel />));
363 expect(result.width).toBe(400);
364 });
365});