diff --git a/src/app/App.tsx b/src/app/App.tsx index 7eae827..2c65024 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -32,6 +32,7 @@ export function App() {
-
+
diff --git a/src/app/components/ResizeHandle.tsx b/src/app/components/ResizeHandle.tsx index 6f401fd..b79af50 100644 --- a/src/app/components/ResizeHandle.tsx +++ b/src/app/components/ResizeHandle.tsx @@ -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 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 { 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(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) => { @@ -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) => { - 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.
usePanesStore.getState(); @@ -32,6 +40,38 @@ describe('clampSideWidth', () => { }); }); +describe('sideWidthValue (aria-valuenow, 0–100 per WAI-ARIA APG window splitter)', () => { + const W = 1400; + + test('reports 0 at the pane minimum and 100 at the pane maximum', () => { + const other = PANE_DEFAULT.preview; + const max = maxSideWidth('library', W, other); + expect(sideWidthValue('library', PANE_MIN.library, W, other)).toBe(0); + expect(sideWidthValue('library', max, W, other)).toBe(100); + }); + + test('reports the midpoint as ~50', () => { + const other = PANE_DEFAULT.preview; + const mid = (PANE_MIN.library + maxSideWidth('library', W, other)) / 2; + expect(sideWidthValue('library', mid, W, other)).toBe(50); + }); + + test('clamps out-of-range widths into 0–100', () => { + const other = PANE_DEFAULT.preview; + expect(sideWidthValue('library', 0, W, other)).toBe(0); + expect(sideWidthValue('library', 99999, W, other)).toBe(100); + }); + + test('returns null before layout is known (container width 0)', () => { + expect(sideWidthValue('library', 280, 0, PANE_DEFAULT.preview)).toBeNull(); + }); + + test('returns null when the range has collapsed (container too narrow)', () => { + // 500px can't fit all the minimums, so min == max and there is no range. + expect(sideWidthValue('library', 200, 500, PANE_MIN.preview)).toBeNull(); + }); +}); + describe('usePanesStore', () => { beforeEach(() => store().hydrate({ libraryWidth: PANE_DEFAULT.library, previewWidth: PANE_DEFAULT.preview }), diff --git a/src/app/stores/PanesStore.ts b/src/app/stores/PanesStore.ts index be4e745..a3463ff 100644 --- a/src/app/stores/PanesStore.ts +++ b/src/app/stores/PanesStore.ts @@ -31,11 +31,19 @@ export const PANE_DEFAULT = { library: 280, preview: 360 } as const; /** Combined width (px) the resize handles occupy between the panes. */ export const HANDLES_TOTAL = 12; +/** + * The widest a side pane can be while the editor still meets its minimum. + * `containerWidth` is the full panes-row width; `otherWidth` is the opposite + * side pane's current width. Never reports below the pane's own minimum. + */ +export function maxSideWidth(side: PaneSide, containerWidth: number, otherWidth: number): number { + return Math.max(PANE_MIN[side], containerWidth - otherWidth - HANDLES_TOTAL - PANE_MIN.editor); +} + /** * Clamp a desired side-pane width so it stays at least its own minimum and * leaves the editor at least its minimum. Pure — the single place the resize - * constraint lives. `containerWidth` is the full panes-row width; `otherWidth` - * is the opposite side pane's current width. + * constraint lives. */ export function clampSideWidth( side: PaneSide, @@ -43,11 +51,32 @@ export function clampSideWidth( containerWidth: number, otherWidth: number, ): number { + return Math.max( + PANE_MIN[side], + Math.min(desired, maxSideWidth(side, containerWidth, otherWidth)), + ); +} + +/** + * Normalize a side pane's width to the 0–100 position a window splitter reports + * via `aria-valuenow` (WAI-ARIA APG → Window Splitter): 0 = pane at its minimum + * size, 100 = pane at its maximum. Returns null when the container width isn't + * known yet (pre-layout) or the range has collapsed, so the caller omits the + * attribute rather than emitting NaN. The 0–100 scale is APG's "typical" choice + * and announces as a percentage — more meaningful than a moving pixel count. + */ +export function sideWidthValue( + side: PaneSide, + width: number, + containerWidth: number, + otherWidth: number, +): number | null { + if (containerWidth <= 0) return null; const min = PANE_MIN[side]; - // The widest this pane can be while the editor still meets its minimum. - const max = containerWidth - otherWidth - HANDLES_TOTAL - PANE_MIN.editor; - // If the container is too narrow for everyone, the min wins (never below it). - return Math.max(min, Math.min(desired, Math.max(min, max))); + const max = maxSideWidth(side, containerWidth, otherWidth); + if (max <= min) return null; + const pct = ((width - min) / (max - min)) * 100; + return Math.round(Math.min(100, Math.max(0, pct))); } export interface PanesState {