) => {
- 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 {