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
<script lang="ts">
interface WindowSplitterProps {
/** 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;
[key: string]: unknown;
}
let {
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,
...restProps
}: WindowSplitterProps = $props();
// Utility function
function clamp(value: number, minVal: number, maxVal: number): number {
return Math.min(maxVal, Math.max(minVal, value));
}
// Refs
let splitterEl: HTMLDivElement | null = null;
let containerEl: HTMLDivElement | null = null;
let isDragging = $state(false);
// State - capture initial prop values (intentionally not reactive to prop changes)
// Using IIFE to avoid Svelte's state_referenced_locally warning
const { initPosition, initCollapsed, initPreviousPosition } = (() => {
const collapsed = defaultCollapsed;
const pos = collapsed ? 0 : clamp(defaultPosition, min, max);
const prevPos = collapsed ? null : clamp(defaultPosition, min, max);
return { initPosition: pos, initCollapsed: collapsed, initPreviousPosition: prevPos };
})();
let position = $state(initPosition);
let collapsed = $state(initCollapsed);
let previousPosition: number | null = initPreviousPosition;
// Computed
const isVertical = $derived(orientation === 'vertical');
const isHorizontal = $derived(orientation === 'horizontal');
const isRTL = $derived(
dir === 'rtl' || (dir !== 'ltr' && typeof document !== 'undefined' && document.dir === 'rtl')
);
const ariaControls = $derived(
secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId
);
// Update position and emit
function updatePosition(newPosition: number) {
const clampedPosition = clamp(newPosition, min, max);
if (clampedPosition !== position) {
position = clampedPosition;
const sizeInPx = containerEl
? (clampedPosition / 100) *
(isHorizontal ? containerEl.offsetWidth : containerEl.offsetHeight)
: 0;
onpositionchange?.(clampedPosition, sizeInPx);
}
}
// Handle collapse/expand
function handleToggleCollapse() {
if (!collapsible || disabled || readonly) return;
if (collapsed) {
// Expand: restore to previous or fallback
const restorePosition = previousPosition ?? expandedPosition ?? defaultPosition ?? 50;
const clampedRestore = clamp(restorePosition, min, max);
oncollapsedchange?.(false, position);
collapsed = false;
position = clampedRestore;
const sizeInPx = containerEl
? (clampedRestore / 100) *
(isHorizontal ? containerEl.offsetWidth : containerEl.offsetHeight)
: 0;
onpositionchange?.(clampedRestore, sizeInPx);
} else {
// Collapse: save current position, set to 0
previousPosition = position;
oncollapsedchange?.(true, position);
collapsed = true;
position = 0;
onpositionchange?.(0, 0);
}
}
// Keyboard handler
function handleKeyDown(event: KeyboardEvent) {
if (disabled || readonly) return;
const hasShift = event.shiftKey;
const currentStep = hasShift ? largeStep : step;
let delta = 0;
let handled = false;
switch (event.key) {
case 'ArrowRight':
if (!isHorizontal) break;
delta = isRTL ? -currentStep : currentStep;
handled = true;
break;
case 'ArrowLeft':
if (!isHorizontal) break;
delta = isRTL ? currentStep : -currentStep;
handled = true;
break;
case 'ArrowUp':
if (!isVertical) break;
delta = currentStep;
handled = true;
break;
case 'ArrowDown':
if (!isVertical) break;
delta = -currentStep;
handled = true;
break;
case 'Enter':
handleToggleCollapse();
handled = true;
break;
case 'Home':
updatePosition(min);
handled = true;
break;
case 'End':
updatePosition(max);
handled = true;
break;
}
if (handled) {
event.preventDefault();
if (delta !== 0) {
updatePosition(position + delta);
}
}
}
// Pointer handlers
function handlePointerDown(event: PointerEvent) {
if (disabled || readonly) return;
event.preventDefault();
if (!splitterEl) return;
if (typeof splitterEl.setPointerCapture === 'function') {
splitterEl.setPointerCapture(event.pointerId);
}
isDragging = true;
splitterEl.focus();
}
function handlePointerMove(event: PointerEvent) {
if (!isDragging) return;
if (!containerEl) return;
// Use demo container for stable measurement if available
const demoContainer = containerEl.closest(
'.apg-window-splitter-demo-container'
) as HTMLElement | null;
const measureElement = demoContainer || containerEl.parentElement || containerEl;
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);
}
function handlePointerUp(event: PointerEvent) {
if (splitterEl && typeof splitterEl.releasePointerCapture === 'function') {
try {
splitterEl.releasePointerCapture(event.pointerId);
} catch {
// Ignore
}
}
isDragging = false;
}
</script>
<div
bind:this={containerEl}
class="apg-window-splitter {isVertical ? 'apg-window-splitter--vertical' : ''} {disabled
? 'apg-window-splitter--disabled'
: ''} {restProps.class || ''}"
style="--splitter-position: {position}%"
>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- role="separator" with aria-valuenow is a focusable widget per WAI-ARIA spec -->
<div
bind:this={splitterEl}
role="separator"
id={restProps.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={restProps['aria-label']}
aria-labelledby={restProps['aria-labelledby']}
aria-describedby={restProps['aria-describedby']}
data-testid={restProps['data-testid']}
class="apg-window-splitter__separator"
onkeydown={handleKeyDown}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
></div>
</div> Usage
<script lang="ts">
import WindowSplitter from './WindowSplitter.svelte';
function handlePositionChange(position: number, sizeInPx: number) {
console.log('Position:', position, 'Size:', sizeInPx);
}
</script>
<div class="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={handlePositionChange}
/>
<div id="secondary-pane">
Secondary Content
</div>
</div> API
| Prop | Type | Default | Description |
|---|---|---|---|
primaryPaneId | string | required | ID of primary pane |
secondaryPaneId | string | - | ID of secondary pane |
defaultPosition | number | 50 | Initial position (%) |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Splitter orientation |
disabled | boolean | false | Disabled state |
readonly | boolean | false | Readonly state |
Custom Events
| Event | Detail | Description |
|---|---|---|
onpositionchange | (position: number, sizeInPx: number) => void | Called when position changes |
oncollapsedchange | (collapsed: boolean, previousPosition: number) => void | Called 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 } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
import WindowSplitter from './WindowSplitter.svelte';
import WindowSplitterWithLabel from './WindowSplitterWithLabel.test.svelte';
import WindowSplitterWithDescribedby from './WindowSplitterWithDescribedby.test.svelte';
import WindowSplitterWithPanes from './WindowSplitterWithPanes.test.svelte';
describe('WindowSplitter (Svelte)', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="separator"', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
expect(screen.getByRole('separator')).toBeInTheDocument();
});
it('has aria-valuenow set to current position (default: 50)', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('has aria-valuenow set to custom defaultPosition', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 30,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuenow', '30');
});
it('has aria-valuemin set (default: 10)', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemin', '10');
});
it('has aria-valuemax set (default: 90)', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemax', '90');
});
it('has custom aria-valuemin when provided', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', min: 5, 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemin', '5');
});
it('has custom aria-valuemax when provided', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', max: 95, 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemax', '95');
});
it('has aria-controls referencing primary pane', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'main-panel', 'aria-label': 'Resize panels' },
});
expect(screen.getByRole('separator')).toHaveAttribute('aria-controls', 'main-panel');
});
it('has aria-controls referencing both panes when secondaryPaneId provided', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
secondaryPaneId: 'secondary',
'aria-label': 'Resize panels',
},
});
expect(screen.getByRole('separator')).toHaveAttribute('aria-controls', 'primary secondary');
});
it('has aria-valuenow="0" when collapsed', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultCollapsed: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuenow', '0');
});
it('has aria-disabled="true" when disabled', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
disabled: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-disabled', 'true');
});
it('does not have aria-disabled when not disabled', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).not.toHaveAttribute('aria-disabled');
});
// Note: aria-readonly is not a valid attribute for role="separator"
// Readonly behavior is enforced via JavaScript only
it('clamps defaultPosition to min', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 5,
min: 10,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuenow', '10');
});
it('clamps defaultPosition to max', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 95,
max: 90,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuenow', '90');
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has accessible name via aria-label', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
expect(screen.getByRole('separator', { name: 'Resize panels' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(WindowSplitterWithLabel);
expect(screen.getByRole('separator', { name: 'Panel Divider' })).toBeInTheDocument();
});
it('supports aria-describedby', () => {
render(WindowSplitterWithDescribedby);
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-describedby', 'help');
});
});
// 🔴 High Priority: Orientation
describe('Orientation', () => {
it('does not have aria-orientation for horizontal splitter (default)', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).not.toHaveAttribute('aria-orientation');
});
it('has aria-orientation="vertical" for vertical splitter', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
orientation: 'vertical',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-orientation', 'vertical');
});
});
// 🔴 High Priority: Keyboard Interaction - Horizontal
describe('Keyboard Interaction - Horizontal', () => {
it('increases value by step on ArrowRight', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '55');
});
it('decreases value by step on ArrowLeft', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowLeft}');
expect(separator).toHaveAttribute('aria-valuenow', '45');
});
it('increases value by largeStep on Shift+ArrowRight', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
largeStep: 10,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Shift>}{ArrowRight}{/Shift}');
expect(separator).toHaveAttribute('aria-valuenow', '60');
});
it('decreases value by largeStep on Shift+ArrowLeft', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
largeStep: 10,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Shift>}{ArrowLeft}{/Shift}');
expect(separator).toHaveAttribute('aria-valuenow', '40');
});
it('ignores ArrowUp on horizontal splitter', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowUp}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('ignores ArrowDown on horizontal splitter', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowDown}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
});
// 🔴 High Priority: Keyboard Interaction - Vertical
describe('Keyboard Interaction - Vertical', () => {
it('increases value by step on ArrowUp', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
orientation: 'vertical',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowUp}');
expect(separator).toHaveAttribute('aria-valuenow', '55');
});
it('decreases value by step on ArrowDown', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
orientation: 'vertical',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowDown}');
expect(separator).toHaveAttribute('aria-valuenow', '45');
});
it('ignores ArrowLeft on vertical splitter', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
orientation: 'vertical',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowLeft}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('ignores ArrowRight on vertical splitter', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
orientation: 'vertical',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
});
// 🔴 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, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}');
expect(separator).toHaveAttribute('aria-valuenow', '0');
});
it('restores previous value on Enter after collapse', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}'); // Collapse
await user.keyboard('{Enter}'); // Expand
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('expands to expandedPosition when initially collapsed', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultCollapsed: true,
expandedPosition: 30,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}'); // Expand
expect(separator).toHaveAttribute('aria-valuenow', '30');
});
it('does not collapse when collapsible is false', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
collapsible: false,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('remembers position across multiple collapse/expand cycles', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}'); // 55
await user.keyboard('{Enter}'); // Collapse → 0
expect(separator).toHaveAttribute('aria-valuenow', '0');
await user.keyboard('{Enter}'); // Expand → 55
expect(separator).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, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
min: 10,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Home}');
expect(separator).toHaveAttribute('aria-valuenow', '10');
});
it('sets max value on End', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
max: 90,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{End}');
expect(separator).toHaveAttribute('aria-valuenow', '90');
});
});
// 🔴 High Priority: Keyboard Interaction - RTL
describe('Keyboard Interaction - RTL', () => {
it('ArrowLeft increases value in RTL mode (horizontal)', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
dir: 'rtl',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowLeft}');
expect(separator).toHaveAttribute('aria-valuenow', '55');
});
it('ArrowRight decreases value in RTL mode (horizontal)', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
dir: 'rtl',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).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, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
disabled: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
separator.focus();
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('does not change value when readonly', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
readonly: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('does not collapse when disabled', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
disabled: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
separator.focus();
await user.keyboard('{Enter}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('does not collapse when readonly', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
readonly: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" on separator', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('tabindex', '0');
});
it('has tabindex="-1" when disabled', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
disabled: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('tabindex', '-1');
});
it('is focusable when readonly', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
readonly: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('tabindex', '0');
});
it('can be focused via click', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
await user.click(separator);
expect(document.activeElement).toBe(separator);
});
});
// 🟡 Medium Priority: Pointer Interaction
describe('Pointer Interaction', () => {
it('focuses separator on pointer down', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
await user.pointer({ target: separator, keys: '[MouseLeft>]' });
expect(document.activeElement).toBe(separator);
});
it('does not start drag when disabled', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
disabled: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
// tabindex should be -1 when disabled
expect(separator).toHaveAttribute('tabindex', '-1');
});
it('does not start drag when readonly', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
readonly: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
// readonly should still be focusable (tabindex="0")
expect(separator).toHaveAttribute('tabindex', '0');
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(WindowSplitterWithPanes, {
props: { 'aria-label': 'Resize panels' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when collapsed', async () => {
const { container } = render(WindowSplitterWithPanes, {
props: { defaultCollapsed: true, 'aria-label': 'Resize panels' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = render(WindowSplitterWithPanes, {
props: { disabled: true, 'aria-label': 'Resize panels' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations for vertical splitter', async () => {
const { container } = render(WindowSplitterWithPanes, {
props: { orientation: 'vertical', 'aria-label': 'Resize panels' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with aria-labelledby', async () => {
const { container } = render(WindowSplitterWithLabel);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Callbacks
describe('Callbacks', () => {
it('calls onpositionchange on keyboard interaction', async () => {
const user = userEvent.setup();
let capturedPosition: number | undefined;
let capturedSizeInPx: number | undefined;
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
'aria-label': 'Resize panels',
onpositionchange: (pos: number, size: number) => {
capturedPosition = pos;
capturedSizeInPx = size;
},
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(capturedPosition).toBe(55);
expect(typeof capturedSizeInPx).toBe('number');
});
it('calls oncollapsedchange on collapse', async () => {
const user = userEvent.setup();
let capturedCollapsed: boolean | undefined;
let capturedPreviousPosition: number | undefined;
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
oncollapsedchange: (collapsed: boolean, prevPos: number) => {
capturedCollapsed = collapsed;
capturedPreviousPosition = prevPos;
},
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}');
expect(capturedCollapsed).toBe(true);
expect(capturedPreviousPosition).toBe(50);
});
it('calls oncollapsedchange on expand', async () => {
const user = userEvent.setup();
let capturedCollapsed: boolean | undefined;
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultCollapsed: true,
'aria-label': 'Resize panels',
oncollapsedchange: (collapsed: boolean) => {
capturedCollapsed = collapsed;
},
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}');
expect(capturedCollapsed).toBe(false);
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('does not exceed max on ArrowRight', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 88,
max: 90,
step: 5,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '90');
});
it('does not go below min on ArrowLeft', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 12,
min: 10,
step: 5,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowLeft}');
expect(separator).toHaveAttribute('aria-valuenow', '10');
});
it('handles min=0 max=100 range', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
min: 0,
max: 100,
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemin', '0');
expect(separator).toHaveAttribute('aria-valuemax', '100');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('handles custom min/max range', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
min: 20,
max: 80,
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemin', '20');
expect(separator).toHaveAttribute('aria-valuemax', '80');
});
it('prevents default on handled keyboard events', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '55');
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('applies class to container', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
'aria-label': 'Resize panels',
class: 'custom-splitter',
},
});
const container = screen.getByRole('separator').closest('.apg-window-splitter');
expect(container).toHaveClass('custom-splitter');
});
it('sets id attribute on separator element', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
'aria-label': 'Resize panels',
id: 'my-splitter',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('id', 'my-splitter');
});
it('passes through data-* attributes', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
'aria-label': 'Resize panels',
'data-testid': 'custom-splitter',
},
});
expect(screen.getByTestId('custom-splitter')).toBeInTheDocument();
});
});
}); 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