191 lines
5.2 KiB
TypeScript
191 lines
5.2 KiB
TypeScript
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<HTMLDivElement>) => 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<number>(() => 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<HTMLDivElement>) => {
|
|
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,
|
|
};
|
|
}
|