/** * ResizeHandle is the thin vertical grip between two columns. Dragging it * with the pointer resizes the column on its left; the arrow keys resize it * step by step, Home / End jump to the bounds, and a double-click restores * the default width. It exposes the ARIA separator pattern so the current * width is readable by assistive technology. * * The handle owns no width itself: it reports the wanted width through * `onResize` and the parent decides (and clamps) what to apply. * * @example * */ import { type KeyboardEvent, type PointerEvent as ReactPointerEvent, useRef, useState, } from "react"; /** Pixels moved per arrow-key press. */ export const KEYBOARD_RESIZE_STEP = 16; export interface ResizeHandleProps { /** Accessible name of the separator. */ label: string; /** Current width of the resized column, in pixels. */ width: number; /** Narrowest width the parent accepts, in pixels. */ min: number; /** Widest width the parent accepts, in pixels. */ max: number; /** Called with the wanted width whenever the user drags or presses a key. */ onResize: (width: number) => void; /** Called on double-click to restore the default width. */ onReset: () => void; } interface DragOrigin { pointerX: number; width: number; } export function ResizeHandle({ label, width, min, max, onResize, onReset, }: ResizeHandleProps) { // The origin of the current drag; null when the pointer is up. const dragOrigin = useRef(null); const [dragging, setDragging] = useState(false); const startDrag = (event: ReactPointerEvent) => { // Prevents the browser from starting a text selection while dragging. event.preventDefault(); dragOrigin.current = { pointerX: event.clientX, width }; // Capture keeps the move/up events coming even when the pointer // leaves the thin handle (jsdom lacks the API, hence the guard). event.currentTarget.setPointerCapture?.(event.pointerId); setDragging(true); }; const moveDrag = (event: ReactPointerEvent) => { const origin = dragOrigin.current; if (origin === null) { return; } onResize(origin.width + event.clientX - origin.pointerX); }; const endDrag = (event: ReactPointerEvent) => { if (dragOrigin.current === null) { return; } dragOrigin.current = null; event.currentTarget.releasePointerCapture?.(event.pointerId); setDragging(false); }; const handleKey = (event: KeyboardEvent) => { const wanted = widthAfterKey(event.key, width, min, max); if (wanted === null) { return; } event.preventDefault(); onResize(wanted); }; return (
); } /** * widthAfterKey maps a key press to the wanted width, or null when the key * is not a resize key so the event keeps its default behaviour. * * @example * widthAfterKey("ArrowRight", 200, 120, 800); // 216 * widthAfterKey("Home", 200, 120, 800); // 120 * widthAfterKey("Enter", 200, 120, 800); // null */ export function widthAfterKey( key: string, width: number, min: number, max: number, ): number | null { switch (key) { case "ArrowLeft": return width - KEYBOARD_RESIZE_STEP; case "ArrowRight": return width + KEYBOARD_RESIZE_STEP; case "Home": return min; case "End": return max; default: return null; } }