1import * as Schema from "effect/Schema";
2import {
3 type PointerEvent as ReactPointerEvent,
4 type KeyboardEvent,
5 useCallback,
6 useLayoutEffect,
7 useRef,
8 useState,
9} from "react";
10
11import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage";
12import { useResizeDrag } from "./useResizeDrag";
13
14const WidthSchema = Schema.Finite;
15
16/**
17 * Custom property the host element must size itself from. The drag handle sits
18 * directly inside the host, and a drag writes this property on it each frame.
19 */
20export const RESIZABLE_WIDTH_PROPERTY = "--resizable-width";
21
22export interface UseResizableWidthOptions {
23 /** localStorage key the persisted width is stored under. */
24 readonly storageKey: string;
25 readonly defaultWidth: number;
26 readonly minWidth: number;
27 readonly maxWidth: number;
28 /**
29 * Which edge of the host element carries the drag handle:
30 * - "left" → panel grows leftward (right-anchored panels)
31 * - "right" → panel grows rightward (left-anchored panels)
32 */
33 readonly edge: "left" | "right";
34}
35
36export interface ResizableWidthHandlers {
37 readonly onKeyDown: (event: KeyboardEvent<HTMLElement>) => void;
38 readonly "aria-valuemin": number;
39 readonly "aria-valuemax": number;
40 readonly "aria-valuenow": number;
41 readonly onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
42 readonly onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void;
43 readonly onPointerUp: (event: ReactPointerEvent<HTMLElement>) => void;
44 readonly onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => void;
45 readonly onLostPointerCapture: (event: ReactPointerEvent<HTMLElement>) => void;
46}
47
48/**
49 * Width state for a side-anchored panel resized via a drag handle on the
50 * specified edge. Width is read on mount or storage-key changes and persisted on
51 * drag-end (not on every rAF tick — would otherwise be ~60 writes/sec).
52 *
53 * During a drag the hook writes `RESIZABLE_WIDTH_PROPERTY` on the handle's
54 * parent, so the panel follows the cursor in the same frame without
55 * re-rendering the owner. `width` state and localStorage commit once when the
56 * user lifts the pointer or the drag is interrupted.
57 */
58export function useResizableWidth(options: UseResizableWidthOptions): {
59 readonly width: number;
60 readonly handlers: ResizableWidthHandlers;
61} {
62 const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options;
63
64 const clamp = useCallback(
65 (value: number): number => {
66 if (!Number.isFinite(value)) return defaultWidth;
67 return Math.max(minWidth, Math.min(maxWidth, value));
68 },
69 [defaultWidth, maxWidth, minWidth],
70 );
71
72 // No cross-tab subscription: panel width is per-window state.
73 const readWidth = () => {
74 if (typeof window === "undefined") return defaultWidth;
75 try {
76 const stored = getLocalStorageItem(storageKey, WidthSchema);
77 return clamp(stored ?? defaultWidth);
78 } catch (error) {
79 console.error("Could not read persisted panel width.", error);
80 return defaultWidth;
81 }
82 };
83 const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() }));
84 // Panels stay mounted across threads; restore the destination width before paint.
85 if (widthState.storageKey !== storageKey) {
86 setWidthState({ storageKey, width: readWidth() });
87 }
88
89 const clampedWidth = clamp(widthState.width);
90 const latestOptions = useRef({ clamp, storageKey, width: clampedWidth });
91 useLayoutEffect(() => {
92 latestOptions.current = { clamp, storageKey, width: clampedWidth };
93 }, [clamp, clampedWidth, storageKey]);
94
95 const { refresh, ...handlers } = useResizeDrag<HTMLElement>((event) => {
96 const host = event.currentTarget.parentElement;
97 // A collapsible host animates width; live drag writes must not. The host
98 // renders its own transition-duration, so override a property it leaves alone.
99 host?.style.setProperty("transition-property", "none");
100 return {
101 size: clampedWidth,
102 edge,
103 resize(value) {
104 const nextWidth = latestOptions.current.clamp(value);
105 host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`);
106 return nextWidth;
107 },
108 finish(finalWidth) {
109 setWidthState({ storageKey, width: finalWidth });
110 // Commit once at drag-end to avoid 60Hz localStorage writes.
111 try {
112 setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema);
113 } catch (error) {
114 console.error("Could not persist panel width.", error);
115 }
116 },
117 cleanup(committed) {
118 host?.style.removeProperty("transition-property");
119 // React skips the write when the rendered width did not change.
120 if (!committed) {
121 host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${latestOptions.current.width}px`);
122 }
123 },
124 };
125 }, storageKey);
126
127 // Bounds can change mid-drag (sidebar opens, window narrows) and the render
128 // rewrites the committed width, so re-apply the live pointer position.
129 useLayoutEffect(refresh, [clamp, clampedWidth, refresh]);
130
131 return {
132 width: clampedWidth,
133 handlers: {
134 ...handlers,
135 "aria-valuemin": minWidth,
136 "aria-valuemax": maxWidth,
137 "aria-valuenow": clampedWidth,
138 onKeyDown(event) {
139 const step = event.shiftKey ? 40 : 10;
140 const delta = edge === "left" ? -step : step;
141 const next =
142 event.key === "ArrowRight"
143 ? clampedWidth + delta
144 : event.key === "ArrowLeft"
145 ? clampedWidth - delta
146 : event.key === "Home"
147 ? minWidth
148 : event.key === "End"
149 ? maxWidth
150 : null;
151 if (next === null) return;
152 event.preventDefault();
153 const width = clamp(next);
154 setWidthState({ storageKey, width });
155 setLocalStorageItem(storageKey, width, WidthSchema);
156 },
157 },
158 };
159}