From 31b1576824e3d669db8a9ed1cf133320ca2ec8f5 Mon Sep 17 00:00:00 2001 From: Baraa Hamodi Date: Thu, 9 Jul 2026 00:33:06 -0700 Subject: [PATCH] fix(core): make Resizable separator keyboard-operable --- .changeset/resizable-keyboard.md | 6 + .../core/src/Resizable/ResizeHandle.test.tsx | 180 ++++++++++++++++++ packages/core/src/Resizable/ResizeHandle.tsx | 12 +- 3 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 .changeset/resizable-keyboard.md create mode 100644 packages/core/src/Resizable/ResizeHandle.test.tsx diff --git a/.changeset/resizable-keyboard.md b/.changeset/resizable-keyboard.md new file mode 100644 index 000000000000..58afbf51d7e2 --- /dev/null +++ b/.changeset/resizable-keyboard.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Resizable: keyboard resizing now works. The keydown handler was attached to a non-focusable child of the separator, so Arrow/Home/End keys never fired for keyboard users; it now lives on the focusable `role="separator"` element per the WAI-ARIA window-splitter pattern. (#3729) +@bhamodi diff --git a/packages/core/src/Resizable/ResizeHandle.test.tsx b/packages/core/src/Resizable/ResizeHandle.test.tsx new file mode 100644 index 000000000000..3eebdf16977e --- /dev/null +++ b/packages/core/src/Resizable/ResizeHandle.test.tsx @@ -0,0 +1,180 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file ResizeHandle.test.tsx + * @input Uses vitest, @testing-library/react, useResizable, ResizeHandle + * @output Unit tests for ResizeHandle keyboard operability and ARIA state + * @position Testing; validates ResizeHandle.tsx implementation + * + * SYNC: When ResizeHandle.tsx changes, update tests to match new behavior + * + * These tests guard the WAI-ARIA window-splitter keyboard contract: the + * keydown handler must live on the focusable `role="separator"` element. + * Keyboard resizing is pure state math in useResizable (no layout + * measurement), so the observable effect asserted here is aria-valuenow, + * which is bound to the region's `_size`. + */ + +import {describe, it, expect, vi} from 'vitest'; +import {render, screen, fireEvent, act} from '@testing-library/react'; +import {ResizeHandle} from './ResizeHandle'; +import type {ResizeHandleProps} from './ResizeHandle'; +import {useResizable} from './useResizable'; +import type {UseResizableSingleConfig} from './useResizable'; + +const KEYBOARD_STEP = 10; +const KEYBOARD_LARGE_STEP = 50; + +function Harness({ + config, + handleProps, +}: { + config?: UseResizableSingleConfig; + handleProps?: Partial; +}) { + const region = useResizable( + config ?? {defaultSize: 200, minSizePx: 100, maxSizePx: 400}, + ); + return ( + + ); +} + +function getSeparator(): HTMLElement { + return screen.getByRole('separator'); +} + +describe('ResizeHandle', () => { + // --- ARIA wiring --- + + it('exposes the region size and bounds via ARIA', () => { + render(); + const separator = getSeparator(); + expect(separator).toHaveAttribute('aria-valuenow', '200'); + expect(separator).toHaveAttribute('aria-valuemin', '100'); + expect(separator).toHaveAttribute('aria-valuemax', '400'); + // Horizontal layout splits along the vertical axis. + expect(separator).toHaveAttribute('aria-orientation', 'vertical'); + expect(separator).toHaveAttribute('aria-label', 'Resize'); + }); + + it('makes the separator focusable', () => { + render(); + const separator = getSeparator(); + expect(separator).toHaveAttribute('tabindex', '0'); + act(() => separator.focus()); + expect(separator).toHaveFocus(); + }); + + // --- Keyboard resizing (the fix: handler lives on the focused separator) --- + + it('grows the panel by a step on ArrowRight', () => { + render(); + const separator = getSeparator(); + act(() => separator.focus()); + fireEvent.keyDown(separator, {key: 'ArrowRight'}); + expect(separator).toHaveAttribute( + 'aria-valuenow', + String(200 + KEYBOARD_STEP), + ); + }); + + it('shrinks the panel by a step on ArrowLeft', () => { + render(); + const separator = getSeparator(); + act(() => separator.focus()); + fireEvent.keyDown(separator, {key: 'ArrowLeft'}); + expect(separator).toHaveAttribute( + 'aria-valuenow', + String(200 - KEYBOARD_STEP), + ); + }); + + it('uses the large step when Shift is held', () => { + render(); + const separator = getSeparator(); + act(() => separator.focus()); + fireEvent.keyDown(separator, {key: 'ArrowRight', shiftKey: true}); + expect(separator).toHaveAttribute( + 'aria-valuenow', + String(200 + KEYBOARD_LARGE_STEP), + ); + }); + + it('jumps to the minimum on Home', () => { + render(); + const separator = getSeparator(); + act(() => separator.focus()); + fireEvent.keyDown(separator, {key: 'Home'}); + expect(separator).toHaveAttribute('aria-valuenow', '100'); + }); + + it('jumps to the maximum on End', () => { + render(); + const separator = getSeparator(); + act(() => separator.focus()); + fireEvent.keyDown(separator, {key: 'End'}); + expect(separator).toHaveAttribute('aria-valuenow', '400'); + }); + + it('resizes along the block axis for a vertical handle', () => { + render( + , + ); + const separator = getSeparator(); + expect(separator).toHaveAttribute('aria-orientation', 'horizontal'); + act(() => separator.focus()); + fireEvent.keyDown(separator, {key: 'ArrowDown'}); + expect(separator).toHaveAttribute( + 'aria-valuenow', + String(200 + KEYBOARD_STEP), + ); + fireEvent.keyDown(separator, {key: 'ArrowUp'}); + expect(separator).toHaveAttribute('aria-valuenow', '200'); + }); + + it('collapses on Enter when the region is collapsible', () => { + render( + , + ); + const separator = getSeparator(); + act(() => separator.focus()); + fireEvent.keyDown(separator, {key: 'Enter'}); + expect(separator).toHaveAttribute('aria-valuenow', '0'); + }); + + // --- Disabled guard --- + + it('ignores keyboard input when disabled', () => { + render(); + const separator = getSeparator(); + expect(separator).toHaveAttribute('tabindex', '-1'); + fireEvent.keyDown(separator, {key: 'ArrowRight'}); + expect(separator).toHaveAttribute('aria-valuenow', '200'); + }); + + // --- Prop composition (ordering choice: handler sits after {...props}) --- + + it('runs a consumer onKeyDown alongside keyboard resizing', () => { + const onKeyDown = vi.fn(); + render(); + const separator = getSeparator(); + act(() => separator.focus()); + fireEvent.keyDown(separator, {key: 'ArrowRight'}); + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(separator).toHaveAttribute( + 'aria-valuenow', + String(200 + KEYBOARD_STEP), + ); + }); +}); diff --git a/packages/core/src/Resizable/ResizeHandle.tsx b/packages/core/src/Resizable/ResizeHandle.tsx index 2eb2a286e991..45626f07f4d5 100644 --- a/packages/core/src/Resizable/ResizeHandle.tsx +++ b/packages/core/src/Resizable/ResizeHandle.tsx @@ -510,7 +510,16 @@ export function ResizeHandle({ ), className, )} - {...props}> + {...props} + // Keyboard resizing must fire from the focusable separator, not a child: + // keydown fires on the focused element and bubbles up, so a handler on a + // descendant never runs (per the WAI-ARIA window-splitter pattern). + // Placed after {...props} and composed with any consumer onKeyDown so + // this accessibility-critical handler can't be clobbered by a passed prop. + onKeyDown={e => { + props.onKeyDown?.(e); + handleKeyDown(e); + }}> {/* Wider invisible hit area for pointer interaction */}
{/* Pill grip indicator — themed via .astryx-resize-handle-pill */} {children ?? (