import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, useLayoutEffect, useState } from "react"; import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; import { useResizeDrag } from "./useResizeDrag"; const WidthSchema = Schema.Finite; /** The host must size itself from this custom property. */ export const RESIZABLE_WIDTH_PROPERTY = "--resizable-width"; export interface UseResizableWidthOptions { readonly storageKey: string; readonly defaultWidth: number; readonly minWidth: number; readonly maxWidth: number; readonly edge: "left" | "right"; } export interface ResizableWidthHandlers { readonly ref: (node: HTMLElement | null) => (() => void) | undefined; readonly onPointerDown: (event: ReactPointerEvent) => void; readonly onPointerMove: (event: ReactPointerEvent) => void; readonly onPointerUp: (event: ReactPointerEvent) => void; readonly onPointerCancel: (event: ReactPointerEvent) => void; readonly onLostPointerCapture: (event: ReactPointerEvent) => void; } /** Persist on drag end to avoid a storage write on every animation frame. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; readonly handlers: ResizableWidthHandlers; } { const { storageKey, defaultWidth, minWidth, maxWidth, edge } = options; const clamp = (value: number): number => { if (!Number.isFinite(value)) return defaultWidth; return Math.max(minWidth, Math.min(maxWidth, value)); }; const readWidth = () => { if (typeof window === "undefined") return defaultWidth; try { const stored = getLocalStorageItem(storageKey, WidthSchema); return clamp(stored ?? defaultWidth); } catch (error) { console.error("Could not read persisted panel width.", error); return defaultWidth; } }; const [widthState, setWidthState] = useState(() => ({ storageKey, width: readWidth() })); if (widthState.storageKey !== storageKey) { setWidthState({ storageKey, width: readWidth() }); } const clampedWidth = clamp(widthState.width); const drag = useResizeDrag((event) => { const host = event.currentTarget.parentElement; // Disable drag transitions through transition-property; the host renders transition-duration. host?.style.setProperty("transition-property", "none"); // The drag commits to the key and width it started from, even if the panel // switches keys before the pointer is released. const startStorageKey = storageKey; const startWidth = clampedWidth; return { width: startWidth, edge, bounds: { min: minWidth, max: maxWidth }, resize(nextWidth) { host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${nextWidth}px`); return nextWidth; }, finish(finalWidth) { setWidthState({ storageKey: startStorageKey, width: finalWidth }); try { setLocalStorageItem(startStorageKey, finalWidth, WidthSchema); } catch (error) { console.error("Could not persist panel width.", error); } }, cleanup(committed) { host?.style.removeProperty("transition-property"); if (!committed) host?.style.setProperty(RESIZABLE_WIDTH_PROPERTY, `${startWidth}px`); }, }; }); // Bounds can change mid-drag (sidebar opens, window narrows), and the render // that carries them rewrites the committed width, so re-apply the live pointer position. const { refresh } = drag; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reapplies the live drag pointer position to the DOM before paint when the bounds change useLayoutEffect(() => refresh({ min: minWidth, max: maxWidth }), [refresh, minWidth, maxWidth]); return { width: clampedWidth, handlers: { ref: drag.ref, onPointerDown: drag.onPointerDown, onPointerMove: drag.onPointerMove, onPointerUp: drag.onPointerUp, onPointerCancel: drag.onPointerCancel, onLostPointerCapture: drag.onLostPointerCapture, }, }; }