Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/resizable-keyboard.md
Original file line number Diff line number Diff line change
@@ -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
180 changes: 180 additions & 0 deletions packages/core/src/Resizable/ResizeHandle.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ResizeHandleProps>;
}) {
const region = useResizable(
config ?? {defaultSize: 200, minSizePx: 100, maxSizePx: 400},
);
return (
<ResizeHandle resizable={region.props} label="Resize" {...handleProps} />
);
}

function getSeparator(): HTMLElement {
return screen.getByRole('separator');
}

describe('ResizeHandle', () => {
// --- ARIA wiring ---

it('exposes the region size and bounds via ARIA', () => {
render(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
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(
<Harness
config={{defaultSize: 200, minSizePx: 100, maxSizePx: 400}}
handleProps={{direction: 'vertical'}}
/>,
);
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(
<Harness
config={{
defaultSize: 200,
minSizePx: 100,
maxSizePx: 400,
collapsible: true,
}}
/>,
);
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(<Harness handleProps={{isDisabled: true}} />);
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(<Harness handleProps={{onKeyDown}} />);
const separator = getSeparator();
act(() => separator.focus());
fireEvent.keyDown(separator, {key: 'ArrowRight'});
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(separator).toHaveAttribute(
'aria-valuenow',
String(200 + KEYBOARD_STEP),
);
});
});
12 changes: 10 additions & 2 deletions packages/core/src/Resizable/ResizeHandle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */}
<div
{...stylex.props(
Expand All @@ -528,7 +537,6 @@ export function ResizeHandle({
setIsHovered(false);
}
}}
onKeyDown={handleKeyDown}
/>
{/* Pill grip indicator — themed via .astryx-resize-handle-pill */}
{children ?? (
Expand Down
Loading