Add resizable panes with drag handles, min widths, and persistence

This commit is contained in:
2026-06-05 10:46:07 +03:00
parent 411bfbc6c2
commit c50f141d57
11 changed files with 482 additions and 25 deletions
+99
View File
@@ -0,0 +1,99 @@
/**
* Vertical drag handle between two panes (spec §01A).
*
* Sits between a side pane and the editor; dragging resizes the side pane while
* the editor absorbs the change, so the opposite side pane is unaffected. Width
* is clamped (pure `clampSideWidth`) so neither the dragged pane nor the editor
* falls below its minimum. Keyboard accessible: focus and use ←/→ to nudge.
*
* The handle reads the panes-row width from its own parent at interaction time,
* so it needs no layout props — it just controls the `side` it is told to.
*/
import { useRef } from 'react';
import { clampSideWidth, usePanesStore, type PaneSide } from '../stores/PanesStore';
import styles from './ResizeHandle.module.css';
/** Keyboard nudge step (px) per arrow press. */
const KEY_STEP = 16;
interface ResizeHandleProps {
/** Which side pane this handle resizes. */
side: PaneSide;
/** Accessible label, e.g. "Resize snippet library". */
label: string;
}
export function ResizeHandle({ side, label }: ResizeHandleProps) {
const ref = useRef<HTMLDivElement>(null);
/** Full panes-row width — the handle's parent (`.panes`). */
const containerWidth = (): number => ref.current?.parentElement?.clientWidth ?? 0;
/** Apply a desired width for this side, clamped against the current layout. */
const applyWidth = (desired: number) => {
const { libraryWidth, previewWidth, setWidth } = usePanesStore.getState();
const other = side === 'library' ? previewWidth : libraryWidth;
setWidth(side, clampSideWidth(side, desired, containerWidth(), other));
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return; // primary button only
e.preventDefault();
const startX = e.clientX;
const startWidth =
side === 'library'
? usePanesStore.getState().libraryWidth
: usePanesStore.getState().previewWidth;
const onMove = (ev: PointerEvent) => {
const delta = ev.clientX - startX;
// The left handle grows its pane as it moves right; the right handle (left
// of the preview) shrinks the preview as it moves right.
const desired = side === 'library' ? startWidth + delta : startWidth - delta;
applyWidth(desired);
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
// While dragging, force the resize cursor and suppress text selection.
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
};
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
const dir = e.key === 'ArrowRight' ? 1 : -1;
const current =
side === 'library'
? usePanesStore.getState().libraryWidth
: usePanesStore.getState().previewWidth;
const delta = side === 'library' ? dir * KEY_STEP : -dir * KEY_STEP;
applyWidth(current + delta);
};
return (
// TODO: a focusable window-splitter should also expose aria-valuenow/min/max
// (the side pane's current/min/max width) so assistive tech can announce the
// size as it changes. Wire it when the pane toggle strip lands in M6.
<div
ref={ref}
className={styles.handle}
role="separator"
aria-orientation="vertical"
aria-label={label}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
>
<span className={styles.grip} aria-hidden="true" />
</div>
);
}