mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Report pane resize handle size to assistive tech (APG window splitter)
This commit is contained in:
+7
-1
@@ -32,6 +32,7 @@ export function App() {
|
|||||||
|
|
||||||
<main className={styles.panes}>
|
<main className={styles.panes}>
|
||||||
<section
|
<section
|
||||||
|
id="pane-library"
|
||||||
className={styles.pane}
|
className={styles.pane}
|
||||||
style={{ width: libraryWidth }}
|
style={{ width: libraryWidth }}
|
||||||
aria-label="Snippet library"
|
aria-label="Snippet library"
|
||||||
@@ -43,7 +44,12 @@ export function App() {
|
|||||||
<SpecEditor />
|
<SpecEditor />
|
||||||
</section>
|
</section>
|
||||||
<ResizeHandle side="preview" label="Resize live preview" />
|
<ResizeHandle side="preview" label="Resize live preview" />
|
||||||
<section className={styles.pane} style={{ width: previewWidth }} aria-label="Live preview">
|
<section
|
||||||
|
id="pane-preview"
|
||||||
|
className={styles.pane}
|
||||||
|
style={{ width: previewWidth }}
|
||||||
|
aria-label="Live preview"
|
||||||
|
>
|
||||||
<LivePreview />
|
<LivePreview />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,14 +4,22 @@
|
|||||||
* Sits between a side pane and the editor; dragging resizes the side pane while
|
* 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
|
* 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
|
* 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,
|
* Accessibility follows WAI-ARIA APG → Window Splitter (see
|
||||||
* so it needs no layout props — it just controls the `side` it is told to.
|
* 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 { useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { clampSideWidth, usePanesStore, type PaneSide } from '../stores/PanesStore';
|
import { clampSideWidth, sideWidthValue, usePanesStore, type PaneSide } from '../stores/PanesStore';
|
||||||
import styles from './ResizeHandle.module.css';
|
import styles from './ResizeHandle.module.css';
|
||||||
|
|
||||||
/** Keyboard nudge step (px) per arrow press. */
|
/** Keyboard nudge step (px) per arrow press. */
|
||||||
@@ -27,14 +35,32 @@ interface ResizeHandleProps {
|
|||||||
export function ResizeHandle({ side, label }: ResizeHandleProps) {
|
export function ResizeHandle({ side, label }: ResizeHandleProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
/** Full panes-row width — the handle's parent (`.panes`). */
|
// This side pane's width and the opposite side pane's, reactively — so the
|
||||||
const containerWidth = (): number => ref.current?.parentElement?.clientWidth ?? 0;
|
// 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. */
|
/** Apply a desired width for this side, clamped against the current layout. */
|
||||||
const applyWidth = (desired: number) => {
|
const applyWidth = (desired: number) => {
|
||||||
const { libraryWidth, previewWidth, setWidth } = usePanesStore.getState();
|
const { libraryWidth, previewWidth, setWidth } = usePanesStore.getState();
|
||||||
const other = side === 'library' ? previewWidth : libraryWidth;
|
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>) => {
|
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -67,28 +93,47 @@ export function ResizeHandle({ side, label }: ResizeHandleProps) {
|
|||||||
document.body.style.userSelect = 'none';
|
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>) => {
|
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 =
|
const current =
|
||||||
side === 'library'
|
side === 'library'
|
||||||
? usePanesStore.getState().libraryWidth
|
? usePanesStore.getState().libraryWidth
|
||||||
: usePanesStore.getState().previewWidth;
|
: usePanesStore.getState().previewWidth;
|
||||||
const delta = side === 'library' ? dir * KEY_STEP : -dir * KEY_STEP;
|
switch (e.key) {
|
||||||
applyWidth(current + delta);
|
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 (
|
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
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={styles.handle}
|
className={styles.handle}
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
aria-label={label}
|
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}
|
tabIndex={0}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { beforeEach, describe, expect, test } from 'vitest';
|
import { beforeEach, describe, expect, test } from 'vitest';
|
||||||
import { clampSideWidth, HANDLES_TOTAL, PANE_DEFAULT, PANE_MIN, usePanesStore } from './PanesStore';
|
import {
|
||||||
|
clampSideWidth,
|
||||||
|
HANDLES_TOTAL,
|
||||||
|
maxSideWidth,
|
||||||
|
PANE_DEFAULT,
|
||||||
|
PANE_MIN,
|
||||||
|
sideWidthValue,
|
||||||
|
usePanesStore,
|
||||||
|
} from './PanesStore';
|
||||||
|
|
||||||
const store = () => usePanesStore.getState();
|
const store = () => 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', () => {
|
describe('usePanesStore', () => {
|
||||||
beforeEach(() =>
|
beforeEach(() =>
|
||||||
store().hydrate({ libraryWidth: PANE_DEFAULT.library, previewWidth: PANE_DEFAULT.preview }),
|
store().hydrate({ libraryWidth: PANE_DEFAULT.library, previewWidth: PANE_DEFAULT.preview }),
|
||||||
|
|||||||
@@ -31,11 +31,19 @@ export const PANE_DEFAULT = { library: 280, preview: 360 } as const;
|
|||||||
/** Combined width (px) the resize handles occupy between the panes. */
|
/** Combined width (px) the resize handles occupy between the panes. */
|
||||||
export const HANDLES_TOTAL = 12;
|
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
|
* 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
|
* leaves the editor at least its minimum. Pure — the single place the resize
|
||||||
* constraint lives. `containerWidth` is the full panes-row width; `otherWidth`
|
* constraint lives.
|
||||||
* is the opposite side pane's current width.
|
|
||||||
*/
|
*/
|
||||||
export function clampSideWidth(
|
export function clampSideWidth(
|
||||||
side: PaneSide,
|
side: PaneSide,
|
||||||
@@ -43,11 +51,32 @@ export function clampSideWidth(
|
|||||||
containerWidth: number,
|
containerWidth: number,
|
||||||
otherWidth: number,
|
otherWidth: number,
|
||||||
): 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];
|
const min = PANE_MIN[side];
|
||||||
// The widest this pane can be while the editor still meets its minimum.
|
const max = maxSideWidth(side, containerWidth, otherWidth);
|
||||||
const max = containerWidth - otherWidth - HANDLES_TOTAL - PANE_MIN.editor;
|
if (max <= min) return null;
|
||||||
// If the container is too narrow for everyone, the min wins (never below it).
|
const pct = ((width - min) / (max - min)) * 100;
|
||||||
return Math.max(min, Math.min(desired, Math.max(min, max)));
|
return Math.round(Math.min(100, Math.max(0, pct)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PanesState {
|
export interface PanesState {
|
||||||
|
|||||||
Reference in New Issue
Block a user