/** * 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. * * Accessibility follows WAI-ARIA APG → Window Splitter (see * docs/architecture/10 §5): a focusable `separator` that reports the controlled * pane's size as `aria-valuenow` on a 0–100 scale (0 = min, 100 = max) and is * driven by ←/→ to nudge plus Home/End to jump to the pane's min/max. (Enter to * collapse waits for the M6 pane-visibility model — there's nothing to collapse * to yet.) * * The handle reads the panes-row width from its own parent: lazily during a * gesture (most accurate mid-drag), and via a ResizeObserver for the reactive * `aria-valuenow` so the announced size tracks window/container resizes too. */ import { useLayoutEffect, useRef, useState } from 'react'; import { useColResizeDrag } from '../hooks/useColResizeDrag'; import { clampSideWidth, sideWidthValue, 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(null); // This side pane's width and the opposite side pane's, reactively — so the // reported value recomputes as either changes. const width = usePanesStore((s) => (side === 'library' ? s.libraryWidth : s.previewWidth)); const otherWidth = usePanesStore((s) => (side === 'library' ? s.previewWidth : s.libraryWidth)); // Observe the panes-row width so aria-valuenow stays correct across window and // container resizes, not only pane drags. const [containerWidth, setContainerWidth] = useState(0); useLayoutEffect(() => { const parent = ref.current?.parentElement; if (!parent) return; setContainerWidth(parent.clientWidth); if (typeof ResizeObserver === 'undefined') return; const ro = new ResizeObserver(() => setContainerWidth(parent.clientWidth)); ro.observe(parent); return () => ro.disconnect(); }, []); /** Live panes-row width for imperative drag/key math (most accurate in-gesture). */ const readContainerWidth = (): 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, readContainerWidth(), other)); }; // 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 onPointerDown = useColResizeDrag( () => side === 'library' ? usePanesStore.getState().libraryWidth : usePanesStore.getState().previewWidth, (startWidth, delta) => applyWidth(side === 'library' ? startWidth + delta : startWidth - delta), ); // Keyboard model per WAI-ARIA APG → Window Splitter: arrows nudge; Home/End jump // to the pane's smallest/largest allowed size (clampSideWidth caps the extremes). const onKeyDown = (e: React.KeyboardEvent) => { const current = side === 'library' ? usePanesStore.getState().libraryWidth : usePanesStore.getState().previewWidth; switch (e.key) { case 'ArrowLeft': case 'ArrowRight': { const dir = e.key === 'ArrowRight' ? 1 : -1; applyWidth(current + (side === 'library' ? dir * KEY_STEP : -dir * KEY_STEP)); break; } case 'Home': // smallest primary-pane size applyWidth(0); break; case 'End': // largest primary-pane size applyWidth(Number.MAX_SAFE_INTEGER); break; default: return; // not ours — let it bubble } e.preventDefault(); }; const valueNow = sideWidthValue(side, width, containerWidth, otherWidth); return (
); }