/**
* 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;
}
}