Files
astrolabe/src/app/components/ResizeHandle.tsx
T

126 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 0100 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<HTMLDivElement>(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<HTMLDivElement>) => {
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 (
<div
ref={ref}
className={styles.handle}
role="separator"
aria-orientation="vertical"
aria-label={label}
// The splitter controls — and reports the size of — its side pane (APG).
aria-controls={`pane-${side}`}
aria-valuenow={valueNow ?? undefined}
aria-valuemin={valueNow === null ? undefined : 0}
aria-valuemax={valueNow === null ? undefined : 100}
aria-valuetext={valueNow === null ? undefined : `${valueNow}%`}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
>
<span className={styles.grip} aria-hidden="true" />
</div>
);
}