| author | |
| committer | |
| log | f5fb056d248be557e14358d08525361eca047e01 |
| tree | da1cac55dd1187ea258bd94f908d1fcbb240c387 |
| parent | 6134b90ffb19c18e0b2f468aa172095a393470b0 |
| signature | Signed by PGP key B5690EEEBB952194 |
2 files changed, 163 insertions(+), 7 deletions(-)
apps/web/src/hooks/useResizableWidth.test.tsx created+127| ... | @@ -0,0 +1,127 @@ | ||
| 1 | import { act, useLayoutEffect, 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 { useResizableWidth } from "./useResizableWidth"; | ||
| 6 | |||
| 7 | let renderer: ReactTestRenderer; | ||
| 8 | let result: ReturnType<typeof useResizableWidth>; | ||
| 9 | let captured = false; | ||
| 10 | const target = { | ||
| 11 | setPointerCapture: () => { | ||
| 12 | captured = true; | ||
| 13 | }, | ||
| 14 | hasPointerCapture: () => captured, | ||
| 15 | releasePointerCapture: () => { | ||
| 16 | captured = false; | ||
| 17 | }, | ||
| 18 | }; | ||
| 19 | const style = { | ||
| 20 | cursor: "", | ||
| 21 | userSelect: "", | ||
| 22 | removeProperty(property: string) { | ||
| 23 | if (property === "cursor") this.cursor = ""; | ||
| 24 | if (property === "user-select") this.userSelect = ""; | ||
| 25 | }, | ||
| 26 | }; | ||
| 27 | const setItem = vi.fn(); | ||
| 28 | const cancelAnimationFrame = vi.fn(); | ||
| 29 | let events: EventTarget; | ||
| 30 | let frame: FrameRequestCallback | undefined; | ||
| 31 | |||
| 32 | function pointer(clientX = 100) { | ||
| 33 | return { | ||
| 34 | button: 0, | ||
| 35 | pointerId: 1, | ||
| 36 | clientX, | ||
| 37 | currentTarget: target, | ||
| 38 | preventDefault() {}, | ||
| 39 | stopPropagation() {}, | ||
| 40 | } as unknown as PointerEvent<HTMLElement>; | ||
| 41 | } | ||
| 42 | |||
| 43 | function Panel() { | ||
| 44 | const resize = useResizableWidth({ | ||
| 45 | storageKey: "test-panel-width", | ||
| 46 | defaultWidth: 400, | ||
| 47 | minWidth: 200, | ||
| 48 | maxWidth: 800, | ||
| 49 | edge: "left", | ||
| 50 | }); | ||
| 51 | useLayoutEffect(() => { | ||
| 52 | result = resize; | ||
| 53 | }); | ||
| 54 | return null; | ||
| 55 | } | ||
| 56 | |||
| 57 | beforeEach(async () => { | ||
| 58 | captured = false; | ||
| 59 | frame = undefined; | ||
| 60 | style.cursor = ""; | ||
| 61 | style.userSelect = ""; | ||
| 62 | events = new EventTarget(); | ||
| 63 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 64 | vi.stubGlobal("window", { | ||
| 65 | addEventListener: events.addEventListener.bind(events), | ||
| 66 | removeEventListener: events.removeEventListener.bind(events), | ||
| 67 | localStorage: { getItem: () => null, setItem }, | ||
| 68 | }); | ||
| 69 | vi.stubGlobal("document", { body: { style } }); | ||
| 70 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { | ||
| 71 | frame = callback; | ||
| 72 | return 42; | ||
| 73 | }); | ||
| 74 | vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); | ||
| 75 | await act(() => { | ||
| 76 | renderer = create(<Panel />); | ||
| 77 | }); | ||
| 78 | }); | ||
| 79 | |||
| 80 | afterEach(async () => { | ||
| 81 | await act(() => renderer.unmount()); | ||
| 82 | vi.unstubAllGlobals(); | ||
| 83 | vi.clearAllMocks(); | ||
| 84 | }); | ||
| 85 | |||
| 86 | describe("panel resize cleanup", () => { | ||
| 87 | it.each(["unmount", "lost capture", "blur", "cancel"])( | ||
| 88 | "clears the cursor and pending resize after %s", | ||
| 89 | async (reason) => { | ||
| 90 | await act(() => { | ||
| 91 | result.handlers.onPointerDown(pointer()); | ||
| 92 | result.handlers.onPointerMove(pointer(50)); | ||
| 93 | }); | ||
| 94 | await act(() => frame?.(0)); | ||
| 95 | expect(result.width).toBe(450); | ||
| 96 | // Queue another move to check that interruption cancels pending work too. | ||
| 97 | await act(() => result.handlers.onPointerMove(pointer(25))); | ||
| 98 | expect(style.cursor).toBe("col-resize"); | ||
| 99 | expect(style.userSelect).toBe("none"); | ||
| 100 | await act(() => { | ||
| 101 | if (reason === "unmount") renderer.unmount(); | ||
| 102 | if (reason === "lost capture") result.handlers.onLostPointerCapture(pointer()); | ||
| 103 | if (reason === "blur") events.dispatchEvent(new Event("blur")); | ||
| 104 | if (reason === "cancel") result.handlers.onPointerCancel(pointer()); | ||
| 105 | }); | ||
| 106 | expect(style.cursor).toBe(""); | ||
| 107 | expect(style.userSelect).toBe(""); | ||
| 108 | expect(captured).toBe(false); | ||
| 109 | expect(cancelAnimationFrame).toHaveBeenCalledWith(42); | ||
| 110 | expect(setItem).not.toHaveBeenCalled(); | ||
| 111 | if (reason !== "unmount") expect(result.width).toBe(400); | ||
| 112 | }, | ||
| 113 | ); | ||
| 114 | |||
| 115 | it("saves the final width when release is followed by lost capture", async () => { | ||
| 116 | await act(() => { | ||
| 117 | result.handlers.onPointerDown(pointer()); | ||
| 118 | result.handlers.onPointerMove(pointer(50)); | ||
| 119 | result.handlers.onPointerUp(pointer(50)); | ||
| 120 | result.handlers.onLostPointerCapture(pointer(50)); | ||
| 121 | }); | ||
| 122 | expect(result.width).toBe(450); | ||
| 123 | expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "450"); | ||
| 124 | expect(style.cursor).toBe(""); | ||
| 125 | expect(captured).toBe(false); | ||
| 126 | }); | ||
| 127 | }); | ||
apps/web/src/hooks/useResizableWidth.ts+36-7| ... | @@ -1,5 +1,11 @@ | ... | @@ -1,5 +1,11 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from "react"; | 2 | import { |
| 3 | type PointerEvent as ReactPointerEvent, | ||
| 4 | useCallback, | ||
| 5 | useEffect, | ||
| 6 | useRef, | ||
| 7 | useState, | ||
| 8 | } from "react"; | ||
| 3 | 9 | ||
| 4 | import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; | 10 | import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; |
| 5 | 11 | ||
| ... | @@ -24,6 +30,7 @@ export interface ResizableWidthHandlers { | ... | @@ -24,6 +30,7 @@ export interface ResizableWidthHandlers { |
| 24 | readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void; | 30 | readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void; |
| 25 | readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void; | 31 | readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void; |
| 26 | readonly onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => void; | 32 | readonly onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => void; |
| 33 | readonly onLostPointerCapture: (event: ReactPointerEvent<HTMLElement>) => void; | ||
| 27 | } | 34 | } |
| 28 | 35 | ||
| 29 | /** | 36 | /** |
| ... | @@ -75,6 +82,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { | ... | @@ -75,6 +82,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 75 | const releasePointer = useCallback((pointerId: number) => { | 82 | const releasePointer = useCallback((pointerId: number) => { |
| 76 | const state = dragStateRef.current; | 83 | const state = dragStateRef.current; |
| 77 | if (!state) return; | 84 | if (!state) return; |
| 85 | // Clear first because releasing capture can trigger another cleanup. | ||
| 86 | dragStateRef.current = null; | ||
| 78 | if (state.rafId !== null) { | 87 | if (state.rafId !== null) { |
| 79 | cancelAnimationFrame(state.rafId); | 88 | cancelAnimationFrame(state.rafId); |
| 80 | } | 89 | } |
| ... | @@ -87,12 +96,27 @@ export function useResizableWidth(options: UseResizableWidthOptions): { | ... | @@ -87,12 +96,27 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 87 | } | 96 | } |
| 88 | document.body.style.removeProperty("cursor"); | 97 | document.body.style.removeProperty("cursor"); |
| 89 | document.body.style.removeProperty("user-select"); | 98 | document.body.style.removeProperty("user-select"); |
| 90 | dragStateRef.current = null; | ||
| 91 | }, []); | 99 | }, []); |
| 92 | 100 | ||
| 101 | const cancelDrag = useCallback(() => { | ||
| 102 | const state = dragStateRef.current; | ||
| 103 | if (!state) return; | ||
| 104 | releasePointer(state.pointerId); | ||
| 105 | setWidth(state.startWidth); | ||
| 106 | }, [releasePointer]); | ||
| 107 | |||
| 108 | useEffect(() => { | ||
| 109 | window.addEventListener("blur", cancelDrag); | ||
| 110 | return () => { | ||
| 111 | window.removeEventListener("blur", cancelDrag); | ||
| 112 | const state = dragStateRef.current; | ||
| 113 | if (state) releasePointer(state.pointerId); | ||
| 114 | }; | ||
| 115 | }, [cancelDrag, releasePointer]); | ||
| 116 | |||
| 93 | const onPointerDown = useCallback( | 117 | const onPointerDown = useCallback( |
| 94 | (event: ReactPointerEvent<HTMLElement>) => { | 118 | (event: ReactPointerEvent<HTMLElement>) => { |
| 95 | if (event.button !== 0) return; | 119 | if (event.button !== 0 || dragStateRef.current) return; |
| 96 | event.preventDefault(); | 120 | event.preventDefault(); |
| 97 | event.stopPropagation(); | 121 | event.stopPropagation(); |
| 98 | const target = event.currentTarget; | 122 | const target = event.currentTarget; |
| ... | @@ -155,14 +179,19 @@ export function useResizableWidth(options: UseResizableWidthOptions): { | ... | @@ -155,14 +179,19 @@ export function useResizableWidth(options: UseResizableWidthOptions): { |
| 155 | const state = dragStateRef.current; | 179 | const state = dragStateRef.current; |
| 156 | if (!state || state.pointerId !== event.pointerId) return; | 180 | if (!state || state.pointerId !== event.pointerId) return; |
| 157 | // Don't persist a cancelled drag; revert to the start width. | 181 | // Don't persist a cancelled drag; revert to the start width. |
| 158 | releasePointer(event.pointerId); | 182 | cancelDrag(); |
| 159 | setWidth(state.startWidth); | ||
| 160 | }, | 183 | }, |
| 161 | [releasePointer], | 184 | [cancelDrag], |
| 162 | ); | 185 | ); |
| 163 | 186 | ||
| 164 | return { | 187 | return { |
| 165 | width: clampedWidth, | 188 | width: clampedWidth, |
| 166 | handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }, | 189 | handlers: { |
| 190 | onPointerDown, | ||
| 191 | onPointerMove, | ||
| 192 | onPointerUp, | ||
| 193 | onPointerCancel, | ||
| 194 | onLostPointerCapture: onPointerCancel, | ||
| 195 | }, | ||
| 167 | }; | 196 | }; |
| 168 | } | 197 | } |