| 1 | import { act, useLayoutEffect, useState, type KeyboardEvent, type PointerEvent } from "react"; |
| 2 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 4 | |
| 5 | import { useResizeDrag } from "./useResizeDrag"; |
| 6 | import { RESIZABLE_WIDTH_PROPERTY, useResizableWidth } from "./useResizableWidth"; |
| 7 | |
| 8 | let renderer: ReactTestRenderer; |
| 9 | let result: ReturnType<typeof useResizableWidth>; |
| 10 | let captured = false; |
| 11 | const hostStyle = new Map<string, string>(); |
| 12 | const 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 | }; |
| 27 | const style = { |
| 28 | cursor: "", |
| 29 | userSelect: "", |
| 30 | removeProperty(property: string) { |
| 31 | if (property === "cursor") this.cursor = ""; |
| 32 | if (property === "user-select") this.userSelect = ""; |
| 33 | }, |
| 34 | }; |
| 35 | const savedWidths = new Map<string, string>(); |
| 36 | const setItem = vi.fn((key: string, value: string) => savedWidths.set(key, value)); |
| 37 | const cancelAnimationFrame = vi.fn(); |
| 38 | let events: EventTarget; |
| 39 | let frame: FrameRequestCallback | undefined; |
| 40 | |
| 41 | function 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 | |
| 53 | function 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 | |
| 75 | let verticalHandlers: ReturnType<typeof useResizeDrag<HTMLElement>>; |
| 76 | let committedHeight = 400; |
| 77 | |
| 78 | function 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 | |
| 102 | beforeEach(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 | |
| 127 | afterEach(async () => { |
| 128 | await act(() => renderer.unmount()); |
| 129 | vi.unstubAllGlobals(); |
| 130 | vi.clearAllMocks(); |
| 131 | }); |
| 132 | |
| 133 | describe("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 | |
| 236 | describe("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 | |
| 269 | describe("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 | |
| 317 | describe("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 | }); |