Window Splitter
A movable separator between two panes that allows users to resize the relative size of each pane. Used in IDEs, file browsers, and resizable layouts.
Demo
Use Arrow keys to move the splitter. Press Enter to collapse/expand. Shift+Arrow moves by a larger step. Home/End moves to min/max position.
- ← / →
- Move horizontal splitter
- ↑ / ↓
- Move vertical splitter
- Shift + Arrow
- Move by large step
- Home / End
- Move to min/max
- Enter
- Collapse/Expand
Horizontal Splitter
Vertical Splitter
Disabled Splitter
Readonly Splitter
Initially Collapsed
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
separator | Splitter element | Focusable separator that controls pane size |
WAI-ARIA Properties
aria-valuenow
Primary pane size as percentage
- Values
- 0-100
- Required
- Yes
aria-valuemin
Minimum value (default: 10)
- Values
- number
- Required
- Yes
aria-valuemax
Maximum value (default: 90)
- Values
- number
- Required
- Yes
aria-controls
Primary pane ID (+ secondary pane ID optional)
- Values
- ID reference(s)
- Required
- Yes
aria-label
Accessible name
- Values
- string
- Required
- Conditional (required if no aria-labelledby)
aria-labelledby
Reference to visible label element
- Values
- ID reference
- Required
- Conditional (required if no aria-label)
aria-orientation
Default: horizontal (left-right split)
- Values
horizontal|vertical- Required
- No
aria-disabled
Disabled state
- Values
- true | false
- Required
- No
WAI-ARIA States
aria-valuenow
- Target Element
- separator element
- Values
0-100 (0 = collapsed, 50 = half, 100 = fully expanded)
- Required
- Yes
- Change Trigger
Arrow keys, Home/End, Enter (collapse/expand), pointer drag
Keyboard Support
| Key | Action |
|---|---|
| Arrow Right / Arrow Left | Move horizontal splitter (increase/decrease) |
| Arrow Up / Arrow Down | Move vertical splitter (increase/decrease) |
| Shift + Arrow | Move by large step (default: 10%) |
| Home | Move to minimum position |
| End | Move to maximum position |
| Enter | Toggle collapse/expand primary pane |
- Arrow keys are direction-restricted based on orientation. Horizontal splitters only respond to Left/Right, vertical splitters only to Up/Down.
- In RTL mode, ArrowLeft increases and ArrowRight decreases for horizontal splitters.
- aria-readonly is NOT valid for role=“separator”. Readonly behavior must be enforced via JavaScript only.
Focus Management
| Event | Behavior |
|---|---|
| Tab | Splitter receives focus via normal tab order |
| Disabled | Splitter is not focusable (tabindex="-1") |
| Readonly | Splitter is focusable but not operable |
| After collapse/expand | Focus remains on splitter |
References
Source Code
import { clsx } from 'clsx';
import type { CSSProperties } from 'react';
import { useCallback, useRef, useState } from 'react';
// CSS custom properties type for splitter position
interface SplitterStyle extends CSSProperties {
'--splitter-position': string;
}
// Label: one of these required (exclusive)
type LabelProps =
| { 'aria-label': string; 'aria-labelledby'?: never }
| { 'aria-label'?: never; 'aria-labelledby': string };
type WindowSplitterBaseProps = {
/** Primary pane ID (required for aria-controls) */
primaryPaneId: string;
/** Secondary pane ID (optional, added to aria-controls) */
secondaryPaneId?: string;
/** Initial position as % (0-100, default: 50) */
defaultPosition?: number;
/** Initial collapsed state (default: false) */
defaultCollapsed?: boolean;
/** Position when expanding from initial collapse */
expandedPosition?: number;
/** Minimum position as % (default: 10) */
min?: number;
/** Maximum position as % (default: 90) */
max?: number;
/** Keyboard step as % (default: 5) */
step?: number;
/** Shift+Arrow step as % (default: 10) */
largeStep?: number;
/** Splitter orientation (default: horizontal = left-right split) */
orientation?: 'horizontal' | 'vertical';
/** Text direction for RTL support */
dir?: 'ltr' | 'rtl';
/** Whether pane can be collapsed (default: true) */
collapsible?: boolean;
/** Disabled state (not focusable, not operable) */
disabled?: boolean;
/** Readonly state (focusable but not operable) */
readonly?: boolean;
/** Callback when position changes */
onPositionChange?: (position: number, sizeInPx: number) => void;
/** Callback when collapsed state changes */
onCollapsedChange?: (collapsed: boolean, previousPosition: number) => void;
/** Reference to help text */
'aria-describedby'?: string;
/** Test id for testing */
'data-testid'?: string;
className?: string;
id?: string;
};
export type WindowSplitterProps = WindowSplitterBaseProps & LabelProps;
// Clamp value to min/max range
const clamp = (value: number, min: number, max: number): number => {
return Math.min(max, Math.max(min, value));
};
export const WindowSplitter: React.FC<WindowSplitterProps> = ({
primaryPaneId,
secondaryPaneId,
defaultPosition = 50,
defaultCollapsed = false,
expandedPosition,
min = 10,
max = 90,
step = 5,
largeStep = 10,
orientation = 'horizontal',
dir,
collapsible = true,
disabled = false,
readonly = false,
onPositionChange,
onCollapsedChange,
'aria-describedby': ariaDescribedby,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
'data-testid': dataTestid,
className,
id,
}) => {
// Calculate initial position: clamp to valid range, or 0 if collapsed
const initialPosition = defaultCollapsed ? 0 : clamp(defaultPosition, min, max);
const [position, setPosition] = useState(initialPosition);
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const splitterRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const previousPositionRef = useRef<number | null>(defaultCollapsed ? null : initialPosition);
const isHorizontal = orientation === 'horizontal';
const isVertical = orientation === 'vertical';
// Determine RTL mode
const isRTL =
dir === 'rtl' ||
(dir === undefined && typeof document !== 'undefined' && document.dir === 'rtl');
// Update position and call callback
const updatePosition = useCallback(
(newPosition: number) => {
const clampedPosition = clamp(newPosition, min, max);
if (clampedPosition !== position) {
setPosition(clampedPosition);
// Calculate size in px (approximation, actual calculation needs container)
const container = containerRef.current;
const sizeInPx = container
? (clampedPosition / 100) *
(isHorizontal ? container.offsetWidth : container.offsetHeight)
: 0;
onPositionChange?.(clampedPosition, sizeInPx);
}
},
[position, min, max, isHorizontal, onPositionChange]
);
// Handle collapse/expand
const handleToggleCollapse = useCallback(() => {
if (!collapsible || disabled || readonly) return;
if (collapsed) {
// Expand: restore to previous or fallback
const restorePosition =
previousPositionRef.current ?? expandedPosition ?? defaultPosition ?? 50;
const clampedRestore = clamp(restorePosition, min, max);
onCollapsedChange?.(false, position);
setCollapsed(false);
setPosition(clampedRestore);
const container = containerRef.current;
const sizeInPx = container
? (clampedRestore / 100) * (isHorizontal ? container.offsetWidth : container.offsetHeight)
: 0;
onPositionChange?.(clampedRestore, sizeInPx);
} else {
// Collapse: save current position, set to 0
previousPositionRef.current = position;
onCollapsedChange?.(true, position);
setCollapsed(true);
setPosition(0);
onPositionChange?.(0, 0);
}
}, [
collapsed,
collapsible,
disabled,
readonly,
position,
expandedPosition,
defaultPosition,
min,
max,
isHorizontal,
onCollapsedChange,
onPositionChange,
]);
// Keyboard handler
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (disabled || readonly) return;
const hasShift = event.shiftKey;
const currentStep = hasShift ? largeStep : step;
let delta = 0;
let handled = false;
switch (event.key) {
// Horizontal splitter: ArrowLeft/Right only
case 'ArrowRight':
if (!isHorizontal) break;
delta = isRTL ? -currentStep : currentStep;
handled = true;
break;
case 'ArrowLeft':
if (!isHorizontal) break;
delta = isRTL ? currentStep : -currentStep;
handled = true;
break;
// Vertical splitter: ArrowUp/Down only
case 'ArrowUp':
if (!isVertical) break;
delta = currentStep;
handled = true;
break;
case 'ArrowDown':
if (!isVertical) break;
delta = -currentStep;
handled = true;
break;
// Collapse/Expand
case 'Enter':
handleToggleCollapse();
handled = true;
break;
// Home/End
case 'Home':
updatePosition(min);
handled = true;
break;
case 'End':
updatePosition(max);
handled = true;
break;
}
if (handled) {
event.preventDefault();
if (delta !== 0) {
updatePosition(position + delta);
}
}
},
[
disabled,
readonly,
isHorizontal,
isVertical,
isRTL,
step,
largeStep,
position,
min,
max,
handleToggleCollapse,
updatePosition,
]
);
// Pointer handlers
const isDraggingRef = useRef(false);
const handlePointerDown = useCallback(
(event: React.PointerEvent) => {
if (disabled || readonly) return;
event.preventDefault();
const splitter = splitterRef.current;
if (!splitter) return;
if (typeof splitter.setPointerCapture === 'function') {
splitter.setPointerCapture(event.pointerId);
}
isDraggingRef.current = true;
splitter.focus();
},
[disabled, readonly]
);
const handlePointerMove = useCallback(
(event: React.PointerEvent) => {
if (!isDraggingRef.current) return;
const container = containerRef.current;
if (!container) return;
// Use demo container for stable measurement if available
const demoContainerElement = container.closest('.apg-window-splitter-demo-container');
const demoContainer =
demoContainerElement instanceof HTMLElement ? demoContainerElement : null;
const measureElement = demoContainer || container.parentElement || container;
const rect = measureElement.getBoundingClientRect();
let percent: number;
if (isHorizontal) {
const x = event.clientX - rect.left;
percent = (x / rect.width) * 100;
} else {
const y = event.clientY - rect.top;
// For vertical, y position corresponds to primary pane height
percent = (y / rect.height) * 100;
}
// Clamp the percent to min/max
const clampedPercent = clamp(percent, min, max);
// Update CSS variable directly for smooth dragging
if (demoContainer) {
demoContainer.style.setProperty('--splitter-position', `${clampedPercent}%`);
}
updatePosition(percent);
},
[isHorizontal, min, max, updatePosition]
);
const handlePointerUp = useCallback((event: React.PointerEvent) => {
const splitter = splitterRef.current;
if (splitter && typeof splitter.releasePointerCapture === 'function') {
try {
splitter.releasePointerCapture(event.pointerId);
} catch {
// Ignore if pointer capture was not set
}
}
isDraggingRef.current = false;
}, []);
// Compute aria-controls
const ariaControls = secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId;
return (
<div
ref={containerRef}
className={clsx(
'apg-window-splitter',
isVertical && 'apg-window-splitter--vertical',
disabled && 'apg-window-splitter--disabled',
className
)}
style={{ '--splitter-position': `${position}%` } satisfies SplitterStyle}
>
{/* role=separator as a interactive element when is focusable */}
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<div
ref={splitterRef}
role="separator"
id={id}
tabIndex={disabled ? -1 : 0}
aria-valuenow={position}
aria-valuemin={min}
aria-valuemax={max}
aria-controls={ariaControls}
aria-orientation={isVertical ? 'vertical' : undefined}
aria-disabled={disabled ? true : undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
data-testid={dataTestid}
className="apg-window-splitter__separator"
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
/>
</div>
);
}; Usage
import { WindowSplitter } from './WindowSplitter';
function App() {
return (
<div className="layout">
<div id="primary-pane" style={{ width: 'var(--splitter-position)' }}>
Primary Content
</div>
<WindowSplitter
primaryPaneId="primary-pane"
secondaryPaneId="secondary-pane"
defaultPosition={50}
min={20}
max={80}
step={5}
aria-label="Resize panels"
onPositionChange={(position, sizeInPx) => {
console.log('Position:', position, 'Size:', sizeInPx);
}}
/>
<div id="secondary-pane">
Secondary Content
</div>
</div>
);
} API
| Prop | Type | Default | Description |
|---|---|---|---|
primaryPaneId | string | required | ID of primary pane (for aria-controls) |
secondaryPaneId | string | - | ID of secondary pane (optional) |
defaultPosition | number | 50 | Initial position as percentage (0-100) |
defaultCollapsed | boolean | false | Start in collapsed state |
expandedPosition | number | - | Position when expanding from initial collapse |
min | number | 10 | Minimum position (%) |
max | number | 90 | Maximum position (%) |
step | number | 5 | Keyboard step size (%) |
largeStep | number | 10 | Shift+Arrow step size (%) |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Splitter orientation |
dir | 'ltr' | 'rtl' | - | Text direction for RTL support |
collapsible | boolean | true | Allow collapse/expand with Enter |
disabled | boolean | false | Disabled state |
readonly | boolean | false | Readonly state (focusable but not operable) |
onPositionChange | (position: number, sizeInPx: number) => void | - | Callback when position changes |
onCollapsedChange | (collapsed: boolean, previousPosition: number) => void | - | Callback when collapsed state changes |
Testing
Tests verify APG compliance across ARIA structure, keyboard navigation, focus management, and pointer interaction. The Window Splitter component uses a two-layer testing strategy.
Testing Strategy
Unit Tests (Container API / Testing Library)
Verify the component's HTML output and basic interactions. These tests ensure correct template rendering and ARIA attributes.
- ARIA structure (role, aria-valuenow, aria-controls)
- Keyboard interaction (Arrow keys, Home/End, Enter)
- Collapse/expand functionality
- RTL support
- Disabled/readonly states
E2E Tests (Playwright)
Verify component behavior in a real browser environment including pointer interactions.
- Pointer drag to resize
- Focus management across Tab navigation
- Cross-framework consistency
- Visual state (CSS custom property updates)
Test Categories
High Priority: ARIA Structure
| Test | Description |
|---|---|
role="separator" | Splitter has separator role |
aria-valuenow | Primary pane size as percentage (0-100) |
aria-valuemin/max | Minimum and maximum values set |
aria-controls | References primary (and optional secondary) pane |
aria-orientation | Set to "vertical" for vertical splitter |
aria-disabled | Set to "true" when disabled |
High Priority: Keyboard Interaction
| Test | Description |
|---|---|
ArrowRight/Left | Moves horizontal splitter (increases/decreases) |
ArrowUp/Down | Moves vertical splitter (increases/decreases) |
Direction restriction | Wrong-direction keys have no effect |
Shift+Arrow | Moves by large step |
Home/End | Moves to min/max position |
Enter (collapse) | Collapses to 0 |
Enter (expand) | Restores previous position |
RTL support | ArrowLeft/Right reversed in RTL mode |
High Priority: Focus Management
| Test | Description |
|---|---|
tabindex="0" | Splitter is focusable |
tabindex="-1" | Disabled splitter is not focusable |
readonly focusable | Readonly splitter is focusable but not operable |
Focus after collapse | Focus remains on splitter |
Medium Priority: Pointer Interaction
| Test | Description |
|---|---|
Drag to resize | Position updates during drag |
Focus on click | Clicking focuses the splitter |
Disabled no response | Disabled splitter ignores pointer |
Readonly no response | Readonly splitter ignores pointer |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations |
Collapsed state | No violations when collapsed |
Disabled state | No violations when disabled |
Running Tests
Unit Tests
# Run all Window Splitter unit tests
npx vitest run src/patterns/window-splitter/
# Run framework-specific tests
npm run test:react -- WindowSplitter.test.tsx
npm run test:vue -- WindowSplitter.test.vue.ts
npm run test:svelte -- WindowSplitter.test.svelte.ts
npm run test:astro E2E Tests
# Run all Window Splitter E2E tests
npm run test:e2e -- window-splitter.spec.ts
# Run in UI mode
npm run test:e2e:ui -- window-splitter.spec.ts Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core (opens in new tab) - Accessibility testing engine
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { WindowSplitter } from './WindowSplitter';
describe('WindowSplitter', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="separator"', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
expect(screen.getByRole('separator')).toBeInTheDocument();
});
it('has aria-valuenow representing primary pane percentage', () => {
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('has aria-valuenow set to defaultPosition (default: 50)', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('has aria-valuenow="0" when collapsed', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}');
expect(splitter).toHaveAttribute('aria-valuenow', '0');
});
it('has aria-valuenow="0" when defaultCollapsed is true', () => {
render(
<WindowSplitter primaryPaneId="primary" defaultCollapsed aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuenow', '0');
});
it('has aria-valuemin set (default: 10)', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuemin', '10');
});
it('has aria-valuemax set (default: 90)', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuemax', '90');
});
it('has custom aria-valuemin when provided', () => {
render(<WindowSplitter primaryPaneId="primary" min={20} aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuemin', '20');
});
it('has custom aria-valuemax when provided', () => {
render(<WindowSplitter primaryPaneId="primary" max={80} aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuemax', '80');
});
it('has aria-controls referencing primary pane', () => {
render(<WindowSplitter primaryPaneId="main-panel" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-controls', 'main-panel');
});
it('has aria-controls with multiple IDs when secondaryPaneId provided', () => {
render(
<WindowSplitter
primaryPaneId="primary"
secondaryPaneId="secondary"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-controls', 'primary secondary');
});
it('does not have aria-orientation for horizontal splitter (default)', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).not.toHaveAttribute('aria-orientation');
});
it('has aria-orientation="vertical" for vertical splitter', () => {
render(
<WindowSplitter primaryPaneId="primary" orientation="vertical" aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-orientation', 'vertical');
});
it('has aria-disabled="true" when disabled', () => {
render(<WindowSplitter primaryPaneId="primary" disabled aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-disabled', 'true');
});
// Note: aria-readonly is not a valid attribute for role="separator"
// Readonly behavior is enforced via JavaScript only
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has accessible name via aria-label', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
expect(screen.getByRole('separator', { name: 'Resize panels' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(
<>
<span id="splitter-label">Adjust panel size</span>
<WindowSplitter primaryPaneId="primary" aria-labelledby="splitter-label" />
</>
);
expect(screen.getByRole('separator', { name: 'Adjust panel size' })).toBeInTheDocument();
});
});
// 🔴 High Priority: Keyboard Interaction - Horizontal Splitter
describe('Keyboard Interaction - Horizontal Splitter', () => {
it('increases value by step on ArrowRight', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '55');
});
it('decreases value by step on ArrowLeft', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowLeft}');
expect(splitter).toHaveAttribute('aria-valuenow', '45');
});
it('ArrowUp does nothing on horizontal splitter', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
orientation="horizontal"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowUp}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('ArrowDown does nothing on horizontal splitter', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
orientation="horizontal"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowDown}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('increases value by largeStep on Shift+ArrowRight', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
largeStep={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Shift>}{ArrowRight}{/Shift}');
expect(splitter).toHaveAttribute('aria-valuenow', '60');
});
it('decreases value by largeStep on Shift+ArrowLeft', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
largeStep={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Shift>}{ArrowLeft}{/Shift}');
expect(splitter).toHaveAttribute('aria-valuenow', '40');
});
});
// 🔴 High Priority: Keyboard Interaction - Vertical Splitter
describe('Keyboard Interaction - Vertical Splitter', () => {
it('increases value by step on ArrowUp', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
orientation="vertical"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowUp}');
expect(splitter).toHaveAttribute('aria-valuenow', '55');
});
it('decreases value by step on ArrowDown', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
orientation="vertical"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowDown}');
expect(splitter).toHaveAttribute('aria-valuenow', '45');
});
it('ArrowLeft does nothing on vertical splitter', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
orientation="vertical"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowLeft}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('ArrowRight does nothing on vertical splitter', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
orientation="vertical"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('increases value by largeStep on Shift+ArrowUp', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
largeStep={10}
orientation="vertical"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Shift>}{ArrowUp}{/Shift}');
expect(splitter).toHaveAttribute('aria-valuenow', '60');
});
});
// 🔴 High Priority: Keyboard Interaction - Collapse/Expand
describe('Keyboard Interaction - Collapse/Expand', () => {
it('collapses on Enter (aria-valuenow becomes 0)', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}');
expect(splitter).toHaveAttribute('aria-valuenow', '0');
});
it('expands to previous value on Enter after collapse', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Collapse → 0
await user.keyboard('{Enter}'); // Expand → 50
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('expands to expandedPosition when initially collapsed', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultCollapsed
expandedPosition={60}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Expand → 60
expect(splitter).toHaveAttribute('aria-valuenow', '60');
});
it('expands to defaultPosition when initially collapsed without expandedPosition', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultCollapsed
defaultPosition={40}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Expand → 40
expect(splitter).toHaveAttribute('aria-valuenow', '40');
});
it('does not collapse when collapsible is false', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
collapsible={false}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('restores correct value after multiple collapse/expand cycles', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}'); // 55
await user.keyboard('{Enter}'); // Collapse → 0
await user.keyboard('{Enter}'); // Expand → 55
expect(splitter).toHaveAttribute('aria-valuenow', '55');
});
});
// 🔴 High Priority: Keyboard Interaction - Home/End
describe('Keyboard Interaction - Home/End', () => {
it('sets min value on Home', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
min={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Home}');
expect(splitter).toHaveAttribute('aria-valuenow', '10');
});
it('sets max value on End', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
max={90}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{End}');
expect(splitter).toHaveAttribute('aria-valuenow', '90');
});
it('does not exceed max on ArrowRight', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={85}
max={90}
step={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '90');
});
it('does not go below min on ArrowLeft', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={15}
min={10}
step={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowLeft}');
expect(splitter).toHaveAttribute('aria-valuenow', '10');
});
});
// 🔴 High Priority: Keyboard Interaction - RTL
describe('Keyboard Interaction - RTL', () => {
it('ArrowLeft increases value in RTL mode', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
dir="rtl"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowLeft}');
expect(splitter).toHaveAttribute('aria-valuenow', '55');
});
it('ArrowRight decreases value in RTL mode', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
dir="rtl"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '45');
});
});
// 🔴 High Priority: Keyboard Interaction - Disabled/Readonly
describe('Keyboard Interaction - Disabled/Readonly', () => {
it('does not change value when disabled', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
disabled
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
splitter.focus();
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('does not change value when readonly', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
readonly
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('does not collapse when disabled', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
disabled
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
splitter.focus();
await user.keyboard('{Enter}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" on splitter', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('tabindex', '0');
});
it('has tabindex="-1" when disabled', () => {
render(<WindowSplitter primaryPaneId="primary" disabled aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('tabindex', '-1');
});
it('has tabindex="0" when readonly (focusable but not operable)', () => {
render(<WindowSplitter primaryPaneId="primary" readonly aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('tabindex', '0');
});
it('is focusable via Tab', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />
<button>After</button>
</>
);
await user.tab(); // Focus "Before" button
await user.tab(); // Focus splitter
expect(screen.getByRole('separator')).toHaveFocus();
});
it('is not focusable via Tab when disabled', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<WindowSplitter primaryPaneId="primary" disabled aria-label="Resize panels" />
<button>After</button>
</>
);
await user.tab(); // Focus "Before" button
await user.tab(); // Skip splitter, focus "After" button
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
it('focus remains on splitter after collapse', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Collapse
expect(splitter).toHaveFocus();
});
});
// 🟡 Medium Priority: Pointer Interaction
describe('Pointer Interaction', () => {
it('updates position on pointer down', () => {
const handleChange = vi.fn();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });
// Focus should be on splitter
expect(splitter).toHaveFocus();
});
it('does not respond to pointer when disabled', () => {
const handleChange = vi.fn();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
disabled
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });
expect(handleChange).not.toHaveBeenCalled();
});
it('does not respond to pointer when readonly', () => {
const handleChange = vi.fn();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
readonly
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });
expect(handleChange).not.toHaveBeenCalled();
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
// Helper: Render with pane elements for aria-controls validation
const renderWithPanes = (
splitterProps: Partial<Parameters<typeof WindowSplitter>[0]> & {
'aria-label'?: string;
'aria-labelledby'?: string;
}
) => {
return render(
<>
<div id="primary">Primary Pane</div>
<WindowSplitter primaryPaneId="primary" {...splitterProps} />
<div id="secondary">Secondary Pane</div>
</>
);
};
it('has no axe violations', async () => {
const { container } = renderWithPanes({ 'aria-label': 'Resize panels' });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with aria-labelledby', async () => {
const { container } = render(
<>
<span id="label">Resize panels</span>
<div id="primary">Primary Pane</div>
<WindowSplitter primaryPaneId="primary" aria-labelledby="label" />
</>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = renderWithPanes({
disabled: true,
'aria-label': 'Resize panels',
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when collapsed', async () => {
const { container } = renderWithPanes({
defaultCollapsed: true,
'aria-label': 'Resize panels',
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations for vertical splitter', async () => {
const { container } = renderWithPanes({
orientation: 'vertical',
'aria-label': 'Resize panels',
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Callbacks
describe('Callbacks', () => {
it('calls onPositionChange on keyboard interaction', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(handleChange).toHaveBeenCalled();
expect(handleChange.mock.calls[0][0]).toBe(55);
});
it('calls onCollapsedChange on collapse', async () => {
const handleCollapse = vi.fn();
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
onCollapsedChange={handleCollapse}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}');
expect(handleCollapse).toHaveBeenCalledWith(true, 50);
});
it('calls onCollapsedChange on expand', async () => {
const handleCollapse = vi.fn();
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultCollapsed
defaultPosition={50}
onCollapsedChange={handleCollapse}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Expand
expect(handleCollapse).toHaveBeenCalledWith(false, 0);
});
it('does not call onPositionChange when disabled', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
disabled
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
splitter.focus();
await user.keyboard('{ArrowRight}');
expect(handleChange).not.toHaveBeenCalled();
});
it('does not call onPositionChange when value does not change', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={90}
max={90}
step={5}
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(handleChange).not.toHaveBeenCalled();
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('clamps defaultPosition to min', () => {
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={5}
min={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuenow', '10');
});
it('clamps defaultPosition to max', () => {
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={95}
max={90}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuenow', '90');
});
it('clamps expandedPosition to min/max', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultCollapsed
expandedPosition={95}
max={90}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Expand
expect(splitter).toHaveAttribute('aria-valuenow', '90');
});
it('uses default step of 5', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '55');
});
it('uses default largeStep of 10', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Shift>}{ArrowRight}{/Shift}');
expect(splitter).toHaveAttribute('aria-valuenow', '60');
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('applies className to container', () => {
render(
<WindowSplitter
primaryPaneId="primary"
aria-label="Resize panels"
className="custom-splitter"
/>
);
const container = screen.getByRole('separator').closest('.apg-window-splitter');
expect(container).toHaveClass('custom-splitter');
});
it('sets id attribute on splitter element', () => {
render(
<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" id="my-splitter" />
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('id', 'my-splitter');
});
it('supports aria-describedby', () => {
render(
<>
<WindowSplitter
primaryPaneId="primary"
aria-label="Resize panels"
aria-describedby="desc"
/>
<p id="desc">Use arrow keys to resize, Enter to collapse</p>
</>
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-describedby', 'desc');
});
});
}); Resources
- WAI-ARIA APG: Window Splitter Pattern (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist