import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, } from 'react'; const DEFAULT_DESKTOP_BREAKPOINT = 1024; type ResizeAxis = 'from-left' | 'from-right'; export type SidePanelMachineOptions = { storageKey: string; defaultWidth: number; minWidth: number; maxWidth: number; resizeAxis: ResizeAxis; resizingBodyClass: string; isOpen: boolean; canResize: boolean; shouldPersistWidth: boolean; closeOnPathname?: string; onCloseOnPathname?: () => void; onEscape?: () => void; desktopBreakpoint?: number; }; export type SidePanelMachineResult = { width: number; isDesktop: boolean; startResize: (event: ReactPointerEvent) => void; }; export function isDesktopViewport(breakpoint = DEFAULT_DESKTOP_BREAKPOINT): boolean { if (!globalThis.window) { return true; } if (typeof globalThis.window.matchMedia === 'function') { return globalThis.window.matchMedia(`(min-width: ${breakpoint}px)`).matches; } return window.innerWidth >= breakpoint; } export function useSidePanelMachine({ storageKey, defaultWidth, minWidth, maxWidth, resizeAxis, resizingBodyClass, isOpen, canResize, shouldPersistWidth, closeOnPathname, onCloseOnPathname, onEscape, desktopBreakpoint = DEFAULT_DESKTOP_BREAKPOINT, }: SidePanelMachineOptions): SidePanelMachineResult { const isResizingRef = useRef(false); const resizeStartXRef = useRef(0); const resizeStartWidthRef = useRef(0); const clampWidth = useCallback( (value: number) => { return Math.min(maxWidth, Math.max(minWidth, value)); }, [maxWidth, minWidth], ); const readStoredWidth = useCallback(() => { if (!globalThis.window) { return defaultWidth; } const storedValue = localStorage.getItem(storageKey); if (storedValue == null || storedValue.trim() === '') { return defaultWidth; } const parsed = Number(storedValue); if (!Number.isFinite(parsed)) { return defaultWidth; } return clampWidth(parsed); }, [defaultWidth, storageKey, clampWidth]); const [width, setWidth] = useState(() => readStoredWidth()); useEffect(() => { if (closeOnPathname == null || !onCloseOnPathname) { return; } onCloseOnPathname(); }, [closeOnPathname, onCloseOnPathname]); useEffect(() => { if (!shouldPersistWidth || !globalThis.window) { return; } localStorage.setItem(storageKey, String(width)); }, [shouldPersistWidth, storageKey, width]); useEffect(() => { function handlePointerMove(event: PointerEvent) { if (!isResizingRef.current || !canResize) { return; } const deltaX = event.clientX - resizeStartXRef.current; const delta = resizeAxis === 'from-left' ? deltaX : -deltaX; const nextWidth = clampWidth(resizeStartWidthRef.current + delta); setWidth(nextWidth); } function stopResizing() { if (!isResizingRef.current) { return; } isResizingRef.current = false; document.body.classList.remove(resizingBodyClass); } globalThis.addEventListener('pointermove', handlePointerMove); globalThis.addEventListener('pointerup', stopResizing); return () => { globalThis.removeEventListener('pointermove', handlePointerMove); globalThis.removeEventListener('pointerup', stopResizing); document.body.classList.remove(resizingBodyClass); }; }, [canResize, clampWidth, resizeAxis, resizingBodyClass]); useEffect(() => { if (!isOpen || isDesktopViewport(desktopBreakpoint)) { return; } const previousOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = previousOverflow; }; }, [isOpen, desktopBreakpoint]); useEffect(() => { if (!isOpen || !onEscape) { return; } const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { onEscape(); } }; globalThis.addEventListener('keydown', handleEscape); return () => { globalThis.removeEventListener('keydown', handleEscape); }; }, [isOpen, onEscape]); const startResize = useCallback( (event: ReactPointerEvent) => { if (!canResize || !isDesktopViewport(desktopBreakpoint)) { return; } isResizingRef.current = true; resizeStartXRef.current = event.clientX; resizeStartWidthRef.current = width; document.body.classList.add(resizingBodyClass); event.preventDefault(); }, [canResize, desktopBreakpoint, resizingBodyClass, width], ); return { width, isDesktop: isDesktopViewport(desktopBreakpoint), startResize, }; }