/** * Vertical drag handle between the library and preview when the editor is hidden * (spec §01A). With no editor between them to absorb the change, the two side * panes share the span: dragging re-splits it — one grows, the other shrinks * (zero-sum) — each kept above its minimum by the pure `splitLibraryWidth`. * * The split is read from the live DOM: the two flanking panes (this handle's * previous/next siblings) render proportionally (`flex: width 1 0`), so writing * their measured widths back makes the boundary track the pointer 1:1, at any * window size. Re-showing the editor later keeps whatever ratio was left here * (PanesStore → `shownSideWidths`). * * Accessibility mirrors the editor-flanking ResizeHandle (WAI-ARIA APG → Window * Splitter): a focusable `separator` reporting the library's 0–100 position via * `aria-valuenow`, driven by ←/→ to nudge and Home/End to jump to min/max. It * reuses ResizeHandle's stylesheet so the two handles look identical. */ import { useRef } from 'react'; import { splitLibraryWidth, splitValue, usePanesStore } from '../stores/PanesStore'; import styles from './ResizeHandle.module.css'; /** Keyboard nudge step (px) per arrow press — matches ResizeHandle. */ const KEY_STEP = 16; interface PaneSplitHandleProps { /** Accessible label, e.g. "Resize library and preview". */ label: string; } export function PaneSplitHandle({ label }: PaneSplitHandleProps) { const ref = useRef(null); // The reported position recomputes from the stored split as it changes. It reads // the *stored* ratio (container-independent, per the file header), so unlike // ResizeHandle aria-valuenow can drift from the rendered position after a window // enlargement (worst near the extremes) — an accepted simplicity trade-off, the // contract in arch/10 (splitter). Drag/keyboard read live clientWidth below, so // resizing itself stays accurate; only the announced value drifts. const libraryWidth = usePanesStore((s) => s.libraryWidth); const previewWidth = usePanesStore((s) => s.previewWidth); const valueNow = splitValue(libraryWidth, previewWidth); /** The panes this handle sits between: library before it, preview after it. */ const flanks = () => ({ lib: ref.current?.previousElementSibling as HTMLElement | null, prev: ref.current?.nextElementSibling as HTMLElement | null, }); /** Apply a desired (rendered) library width, clamped against the live span. */ const applySplit = (desiredLibrary: number) => { const { lib, prev } = flanks(); if (!lib || !prev) return; const avail = lib.clientWidth + prev.clientWidth; const library = splitLibraryWidth(desiredLibrary, avail); usePanesStore.getState().setSplit(library, avail - library); }; const onPointerDown = (e: React.PointerEvent) => { if (e.button !== 0) return; // primary button only e.preventDefault(); const startX = e.clientX; const startLib = flanks().lib?.clientWidth ?? 0; const onMove = (ev: PointerEvent) => applySplit(startLib + (ev.clientX - startX)); 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); document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }; // Keyboard per WAI-ARIA APG → Window Splitter: arrows nudge the library side; // Home/End jump to the library's smallest/largest allowed share of the span. const onKeyDown = (e: React.KeyboardEvent) => { const lib = flanks().lib?.clientWidth ?? 0; switch (e.key) { case 'ArrowLeft': applySplit(lib - KEY_STEP); break; case 'ArrowRight': applySplit(lib + KEY_STEP); break; case 'Home': // library at its minimum applySplit(0); break; case 'End': // library at its maximum (preview at its minimum) applySplit(Number.MAX_SAFE_INTEGER); break; default: return; // not ours — let it bubble } e.preventDefault(); }; return (
); }