Skip to content
Source
Sign inSign up

useResizableWidth.ts

118 lines4.6 KB

1import * as Schema from "effect/Schema";
2import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useState } from "react";
3​
4import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage";
5import { useResizeDrag } from "./useResizeDrag";
6import { useStableCallback } from "./useStableCallback";
7​
8const WidthSchema = Schema.Finite;
9​
10/** The host must size itself from this custom property. */
11export const RESIZABLE_WIDTH_PROPERTY = "--resizable-width";
12​
13export interface UseResizableWidthOptions {
14 readonly storageKey: string;
15 readonly defaultWidth: number;
16 readonly minWidth: number;
17 readonly maxWidth: number;
18 readonly edge: "left" | "right";
20​
21export interface ResizableWidthHandlers {
22 readonly ref: (node: HTMLElement | null) => (() => void) | undefined;
23 readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
24 readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void;
25 readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void;
26 readonly onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => void;
27 readonly onLostPointerCapture: (event: ReactPointerEvent<HTMLElement>) => void;
29​
30/** Uses the current default until a width is chosen; persists on drag end, not every frame. */
31export function useResizableWidth(options: UseResizableWidthOptions): {
32 readonly width: number;
33 readonly setWidth: (width: number) => void;
34 readonly handlers: ResizableWidthHandlers;
35} {
36 const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options;
37​
38 const clamp = (value: number): number => {
39 if (!Number.isFinite(value)) return defaultWidth;
40 return Math.max(minWidth, Math.min(maxWidth, value));
41 };
42​
43 const readWidth = (): number | null => {
44 if (typeof window === "undefined") return null;
45 try {
46 const stored = getLocalStorageItem(storageKey, WidthSchema);
47 return stored === null ? null : clamp(stored);
48 } catch (error) {
49 console.error("Could not read persisted panel width.", error);
50 return null;
51 }
52 };
53 const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() }));
54 if (widthState.storageKey !== storageKey) {
55 setWidthState({ storageKey, width: readWidth() });
56 }
57​
58 const clampedWidth = clamp(widthState.width ?? defaultWidth);
59 const restoreWidth = useStableCallback((host: HTMLElement | null) => {
60 host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${clampedWidth}px`);
61 });
62​
63 const commitWidth = (nextStorageKey: string, nextWidth: number) => {
64 // Resizing remains usable for this session when browser storage is unavailable.
65 setWidthState({ storageKey: nextStorageKey, width: nextWidth });
66 try {
67 setLocalStorageItem(nextStorageKey, nextWidth, WidthSchema);
68 } catch (error) {
69 console.error("Could not persist panel width.", error);
70 }
71 };
72​
73 const drag = useResizeDrag<HTMLElement>((event) => {
74 const host = event.currentTarget.parentElement;
75 // Disable drag transitions through transition-property; the host renders transition-duration.
76 host?.style.setProperty("transition-property", "none");
77 // The drag commits to the key and width it started from, even if the panel
78 // switches keys before the pointer is released.
79 const startStorageKey = storageKey;
80 const startWidth = clampedWidth;
81 return {
82 width: startWidth,
83 edge,
84 bounds: { min: minWidth, max: maxWidth },
85 resize(nextWidth) {
86 host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`);
87 return nextWidth;
88 },
89 finish(finalWidth, moved) {
90 if (moved) commitWidth(startStorageKey, finalWidth);
91 else restoreWidth(host);
92 },
93 cleanup(committed) {
94 host?.style.removeProperty("transition-property");
95 if (!committed) host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${startWidth}px`);
96 },
97 };
98 });
99​
100 // Bounds can change mid-drag (sidebar opens, window narrows), and the render
101 // that carries them rewrites the committed width, so re-apply the live pointer position.
102 const { refresh } = drag;
103 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reapplies the live drag pointer position to the DOM before paint when the bounds change
104 useLayoutEffect(() => refresh({ min: minWidth, max: maxWidth }), [refresh, minWidth, maxWidth]);
105​
106 return {
107 width: clampedWidth,
108 setWidth: (value) => commitWidth(storageKey, clamp(value)),
109 handlers: {
110 ref: drag.ref,
111 onPointerDown: drag.onPointerDown,
112 onPointerMove: drag.onPointerMove,
113 onPointerUp: drag.onPointerUp,
114 onPointerCancel: drag.onPointerCancel,
115 onLostPointerCapture: drag.onLostPointerCapture,
116 },
117 };