Files
web-core/src/panels/useSidePanelMachine.ts
Beatrice Dellacà 7e938138ff
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
fix sidebar width, v0.1.5
2026-02-24 11:02:17 +01:00

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,
};
}