Report pane resize handle size to assistive tech (APG window splitter)

This commit is contained in:
2026-06-05 12:33:19 +03:00
parent 7b8115f623
commit 8ef749c2d0
4 changed files with 144 additions and 24 deletions
+61 -16
View File
@@ -4,14 +4,22 @@
* 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.
* falls below its minimum.
*
* 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.
* 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 { useRef } from 'react';
import { clampSideWidth, usePanesStore, type PaneSide } from '../stores/PanesStore';
import { useLayoutEffect, useRef, useState } from 'react';
import { clampSideWidth, sideWidthValue, usePanesStore, type PaneSide } from '../stores/PanesStore';
import styles from './ResizeHandle.module.css';
/** Keyboard nudge step (px) per arrow press. */
@@ -27,14 +35,32 @@ interface ResizeHandleProps {
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;
// 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, containerWidth(), other));
setWidth(side, clampSideWidth(side, desired, readContainerWidth(), other));
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
@@ -67,28 +93,47 @@ export function ResizeHandle({ side, label }: ResizeHandleProps) {
document.body.style.userSelect = 'none';
};
// 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>) => {
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);
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 (
// 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}
// 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}