Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions apps/web/src/hooks/useResizableWidth.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { act, useLayoutEffect, type PointerEvent } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { useResizableWidth } from "./useResizableWidth";

let renderer: ReactTestRenderer;
let result: ReturnType<typeof useResizableWidth>;
let captured = false;
const target = {
setPointerCapture: () => {
captured = true;
},
hasPointerCapture: () => captured,
releasePointerCapture: () => {
captured = false;
},
};
const style = {
cursor: "",
userSelect: "",
removeProperty(property: string) {
if (property === "cursor") this.cursor = "";
if (property === "user-select") this.userSelect = "";
},
};
const setItem = vi.fn();
const cancelAnimationFrame = vi.fn();
let events: EventTarget;
let frame: FrameRequestCallback | undefined;

function pointer(clientX = 100) {
return {
button: 0,
pointerId: 1,
clientX,
currentTarget: target,
preventDefault() {},
stopPropagation() {},
} as unknown as PointerEvent<HTMLElement>;
}

function Panel() {
const resize = useResizableWidth({
storageKey: "test-panel-width",
defaultWidth: 400,
minWidth: 200,
maxWidth: 800,
edge: "left",
});
useLayoutEffect(() => {
result = resize;
});
return null;
}

beforeEach(async () => {
captured = false;
frame = undefined;
style.cursor = "";
style.userSelect = "";
events = new EventTarget();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", {
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events),
localStorage: { getItem: () => null, setItem },
});
vi.stubGlobal("document", { body: { style } });
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
frame = callback;
return 42;
});
vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame);
await act(() => {
renderer = create(<Panel />);
});
});

afterEach(async () => {
await act(() => renderer.unmount());
vi.unstubAllGlobals();
vi.clearAllMocks();
});

describe("panel resize cleanup", () => {
it.each(["unmount", "lost capture", "blur", "cancel"])(
"clears the cursor and pending resize after %s",
async (reason) => {
await act(() => {
result.handlers.onPointerDown(pointer());
result.handlers.onPointerMove(pointer(50));
});
await act(() => frame?.(0));
expect(result.width).toBe(450);
// Queue another move to check that interruption cancels pending work too.
await act(() => result.handlers.onPointerMove(pointer(25)));
expect(style.cursor).toBe("col-resize");
expect(style.userSelect).toBe("none");
await act(() => {
if (reason === "unmount") renderer.unmount();
if (reason === "lost capture") result.handlers.onLostPointerCapture(pointer());
if (reason === "blur") events.dispatchEvent(new Event("blur"));
if (reason === "cancel") result.handlers.onPointerCancel(pointer());
});
expect(style.cursor).toBe("");
expect(style.userSelect).toBe("");
expect(captured).toBe(false);
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
expect(setItem).not.toHaveBeenCalled();
if (reason !== "unmount") expect(result.width).toBe(400);
},
);

it("saves the final width when release is followed by lost capture", async () => {
await act(() => {
result.handlers.onPointerDown(pointer());
result.handlers.onPointerMove(pointer(50));
result.handlers.onPointerUp(pointer(50));
result.handlers.onLostPointerCapture(pointer(50));
});
expect(result.width).toBe(450);
expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "450");
expect(style.cursor).toBe("");
expect(captured).toBe(false);
});
});
43 changes: 36 additions & 7 deletions apps/web/src/hooks/useResizableWidth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import * as Schema from "effect/Schema";
import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from "react";
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";

import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage";

Expand All @@ -24,6 +30,7 @@ export interface ResizableWidthHandlers {
readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void;
readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void;
readonly onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => void;
readonly onLostPointerCapture: (event: ReactPointerEvent<HTMLElement>) => void;
}

/**
Expand Down Expand Up @@ -75,6 +82,8 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
const releasePointer = useCallback((pointerId: number) => {
const state = dragStateRef.current;
if (!state) return;
// Clear first because releasing capture can trigger another cleanup.
dragStateRef.current = null;
if (state.rafId !== null) {
cancelAnimationFrame(state.rafId);
}
Expand All @@ -87,12 +96,27 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
}
document.body.style.removeProperty("cursor");
document.body.style.removeProperty("user-select");
dragStateRef.current = null;
}, []);

const cancelDrag = useCallback(() => {
const state = dragStateRef.current;
if (!state) return;
releasePointer(state.pointerId);
setWidth(state.startWidth);
}, [releasePointer]);

useEffect(() => {
window.addEventListener("blur", cancelDrag);
return () => {
window.removeEventListener("blur", cancelDrag);
const state = dragStateRef.current;
if (state) releasePointer(state.pointerId);
};
}, [cancelDrag, releasePointer]);

const onPointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (event.button !== 0) return;
if (event.button !== 0 || dragStateRef.current) return;
event.preventDefault();
event.stopPropagation();
const target = event.currentTarget;
Expand Down Expand Up @@ -155,14 +179,19 @@ export function useResizableWidth(options: UseResizableWidthOptions): {
const state = dragStateRef.current;
if (!state || state.pointerId !== event.pointerId) return;
// Don't persist a cancelled drag; revert to the start width.
releasePointer(event.pointerId);
setWidth(state.startWidth);
cancelDrag();
},
[releasePointer],
[cancelDrag],
);

return {
width: clampedWidth,
handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel },
handlers: {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onLostPointerCapture: onPointerCancel,
},
};
}
Loading