Window Splitter
A movable separator between two panes that allows users to resize the relative size of each pane. Uses Web Components for client-side interactivity.
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 separator role (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Description |
|---|---|---|---|---|
aria-valuenow | separator | 0-100 | Yes | Primary pane size as percentage |
aria-valuemin | separator | number | Yes | Minimum value (default: 10) |
aria-valuemax | separator | number | Yes | Maximum value (default: 90) |
aria-controls | separator | ID reference(s) | Yes | Primary pane ID (+ secondary pane ID optional) |
aria-label | separator | string | Conditional (required if no aria-labelledby) | Accessible name |
aria-labelledby | separator | ID reference | Conditional (required if no aria-label) | Reference to visible label element |
aria-orientation | separator | "horizontal" | "vertical" | No | Default: horizontal (left-right split) |
aria-disabled | separator | true | false | No | Disabled state |
Note: aria-readonly is NOT valid for role="separator". Readonly
behavior must be enforced via JavaScript only.
WAI-ARIA States
aria-valuenow
Current position of the splitter as a percentage (0-100).
| Target | 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 |
| Reference | aria-valuenow (opens in new tab) |
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 |
Note: 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.
Source Code
---
/**
* APG Window Splitter Pattern - Astro Implementation
*
* A movable separator between two panes that allows users to resize
* the relative size of each pane.
* Uses Web Components for interactive behavior.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/
*/
export interface Props {
/** 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;
/** Splitter id */
id?: string;
/** Additional CSS class */
class?: string;
/** Accessible label when no visible label */
'aria-label'?: string;
/** Reference to external label element */
'aria-labelledby'?: string;
/** Reference to description element */
'aria-describedby'?: string;
/** Test id for testing */
'data-testid'?: string;
}
const {
primaryPaneId,
secondaryPaneId,
defaultPosition = 50,
defaultCollapsed = false,
expandedPosition,
min = 10,
max = 90,
step = 5,
largeStep = 10,
orientation = 'horizontal',
dir,
collapsible = true,
disabled = false,
readonly = false,
id,
class: className = '',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
'aria-describedby': ariaDescribedby,
'data-testid': dataTestid,
} = Astro.props;
// Utility function
const clamp = (value: number, minVal: number, maxVal: number): number => {
return Math.min(maxVal, Math.max(minVal, value));
};
// Calculate initial position
const initialPosition = defaultCollapsed ? 0 : clamp(defaultPosition, min, max);
// Compute aria-controls
const ariaControls = secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId;
const isVertical = orientation === 'vertical';
---
<apg-window-splitter
data-min={min}
data-max={max}
data-step={step}
data-large-step={largeStep}
data-orientation={orientation}
data-dir={dir}
data-collapsible={collapsible}
data-disabled={disabled}
data-readonly={readonly}
data-default-position={defaultPosition}
data-expanded-position={expandedPosition}
data-collapsed={defaultCollapsed}
>
<div
class={`apg-window-splitter ${isVertical ? 'apg-window-splitter--vertical' : ''} ${disabled ? 'apg-window-splitter--disabled' : ''} ${className}`.trim()}
style={`--splitter-position: ${initialPosition}%`}
>
<div
role="separator"
id={id}
tabindex={disabled ? -1 : 0}
aria-valuenow={initialPosition}
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}
class="apg-window-splitter__separator"
>
</div>
</div>
</apg-window-splitter>
<script>
class ApgWindowSplitter extends HTMLElement {
private separator: HTMLElement | null = null;
private container: HTMLElement | null = null;
private isDragging = false;
private previousPosition: number | null = null;
private collapsed = false;
// Bound event handlers (stored to properly remove listeners)
private boundHandleKeyDown = this.handleKeyDown.bind(this);
private boundHandlePointerDown = this.handlePointerDown.bind(this);
private boundHandlePointerMove = this.handlePointerMove.bind(this);
private boundHandlePointerUp = this.handlePointerUp.bind(this);
connectedCallback() {
this.separator = this.querySelector('[role="separator"]');
this.container = this.querySelector('.apg-window-splitter');
// Initialize state from data attributes
this.collapsed = this.dataset.collapsed === 'true';
if (!this.collapsed) {
this.previousPosition = this.currentPosition;
}
if (this.separator) {
this.separator.addEventListener('keydown', this.boundHandleKeyDown);
this.separator.addEventListener('pointerdown', this.boundHandlePointerDown);
this.separator.addEventListener('pointermove', this.boundHandlePointerMove);
this.separator.addEventListener('pointerup', this.boundHandlePointerUp);
}
}
disconnectedCallback() {
if (this.separator) {
this.separator.removeEventListener('keydown', this.boundHandleKeyDown);
this.separator.removeEventListener('pointerdown', this.boundHandlePointerDown);
this.separator.removeEventListener('pointermove', this.boundHandlePointerMove);
this.separator.removeEventListener('pointerup', this.boundHandlePointerUp);
}
}
private get min(): number {
return Number(this.dataset.min) || 10;
}
private get max(): number {
return Number(this.dataset.max) || 90;
}
private get step(): number {
return Number(this.dataset.step) || 5;
}
private get largeStep(): number {
return Number(this.dataset.largeStep) || 10;
}
private get orientation(): string {
return this.dataset.orientation || 'horizontal';
}
private get isHorizontal(): boolean {
return this.orientation === 'horizontal';
}
private get isVertical(): boolean {
return this.orientation === 'vertical';
}
private get textDir(): string | undefined {
return this.dataset.dir;
}
private get isRTL(): boolean {
if (this.textDir === 'rtl') return true;
if (this.textDir === 'ltr') return false;
return document.dir === 'rtl';
}
private get isCollapsible(): boolean {
return this.dataset.collapsible !== 'false';
}
private get isDisabled(): boolean {
return this.dataset.disabled === 'true';
}
private get isReadonly(): boolean {
return this.dataset.readonly === 'true';
}
private get defaultPosition(): number {
return Number(this.dataset.defaultPosition) || 50;
}
private get expandedPosition(): number | undefined {
const val = this.dataset.expandedPosition;
return val !== undefined ? Number(val) : undefined;
}
private get currentPosition(): number {
return Number(this.separator?.getAttribute('aria-valuenow')) || 0;
}
private clamp(value: number): number {
return Math.min(this.max, Math.max(this.min, value));
}
private updatePosition(newPosition: number) {
if (!this.separator || this.isDisabled) return;
const clampedPosition = this.clamp(newPosition);
const currentPosition = this.currentPosition;
if (clampedPosition === currentPosition) return;
// Update ARIA
this.separator.setAttribute('aria-valuenow', String(clampedPosition));
// Update visual via CSS custom property
if (this.container) {
this.container.style.setProperty('--splitter-position', `${clampedPosition}%`);
}
// Calculate size in px
let sizeInPx = 0;
if (this.container) {
sizeInPx =
(clampedPosition / 100) *
(this.isHorizontal ? this.container.offsetWidth : this.container.offsetHeight);
}
// Dispatch event
this.dispatchEvent(
new CustomEvent('positionchange', {
detail: { position: clampedPosition, sizeInPx },
bubbles: true,
})
);
}
private handleToggleCollapse() {
if (!this.isCollapsible || this.isDisabled || this.isReadonly) return;
if (!this.separator) return;
if (this.collapsed) {
// Expand
const restorePosition =
this.previousPosition ?? this.expandedPosition ?? this.defaultPosition ?? 50;
const clampedRestore = this.clamp(restorePosition);
this.dispatchEvent(
new CustomEvent('collapsedchange', {
detail: { collapsed: false, previousPosition: this.currentPosition },
bubbles: true,
})
);
this.collapsed = false;
this.separator.setAttribute('aria-valuenow', String(clampedRestore));
if (this.container) {
this.container.style.setProperty('--splitter-position', `${clampedRestore}%`);
}
let sizeInPx = 0;
if (this.container) {
sizeInPx =
(clampedRestore / 100) *
(this.isHorizontal ? this.container.offsetWidth : this.container.offsetHeight);
}
this.dispatchEvent(
new CustomEvent('positionchange', {
detail: { position: clampedRestore, sizeInPx },
bubbles: true,
})
);
} else {
// Collapse
this.previousPosition = this.currentPosition;
this.dispatchEvent(
new CustomEvent('collapsedchange', {
detail: { collapsed: true, previousPosition: this.currentPosition },
bubbles: true,
})
);
this.collapsed = true;
this.separator.setAttribute('aria-valuenow', '0');
if (this.container) {
this.container.style.setProperty('--splitter-position', '0%');
}
this.dispatchEvent(
new CustomEvent('positionchange', {
detail: { position: 0, sizeInPx: 0 },
bubbles: true,
})
);
}
}
private handleKeyDown(event: KeyboardEvent) {
if (this.isDisabled || this.isReadonly) return;
const hasShift = event.shiftKey;
const currentStep = hasShift ? this.largeStep : this.step;
let delta = 0;
let handled = false;
switch (event.key) {
case 'ArrowRight':
if (!this.isHorizontal) break;
delta = this.isRTL ? -currentStep : currentStep;
handled = true;
break;
case 'ArrowLeft':
if (!this.isHorizontal) break;
delta = this.isRTL ? currentStep : -currentStep;
handled = true;
break;
case 'ArrowUp':
if (!this.isVertical) break;
delta = currentStep;
handled = true;
break;
case 'ArrowDown':
if (!this.isVertical) break;
delta = -currentStep;
handled = true;
break;
case 'Enter':
this.handleToggleCollapse();
handled = true;
break;
case 'Home':
this.updatePosition(this.min);
handled = true;
break;
case 'End':
this.updatePosition(this.max);
handled = true;
break;
}
if (handled) {
event.preventDefault();
if (delta !== 0) {
this.updatePosition(this.currentPosition + delta);
}
}
}
private handlePointerDown(event: PointerEvent) {
if (this.isDisabled || this.isReadonly || !this.separator) return;
event.preventDefault();
if (typeof this.separator.setPointerCapture === 'function') {
this.separator.setPointerCapture(event.pointerId);
}
this.isDragging = true;
this.separator.focus();
}
private handlePointerMove(event: PointerEvent) {
if (!this.isDragging || !this.container) return;
// Use demo container for stable measurement if available
const demoContainer = this.container.closest(
'.apg-window-splitter-demo-container'
) as HTMLElement | null;
const measureElement = demoContainer || this.container.parentElement || this.container;
const rect = measureElement.getBoundingClientRect();
let percent: number;
if (this.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 = this.clamp(percent);
// Update CSS variable directly for smooth dragging
if (demoContainer) {
demoContainer.style.setProperty('--splitter-position', `${clampedPercent}%`);
}
this.updatePosition(percent);
}
private handlePointerUp(event: PointerEvent) {
if (this.separator && typeof this.separator.releasePointerCapture === 'function') {
try {
this.separator.releasePointerCapture(event.pointerId);
} catch {
// Ignore
}
}
this.isDragging = false;
}
// Public method to update position programmatically
setPosition(newPosition: number) {
this.updatePosition(newPosition);
}
// Public method to toggle collapse
toggleCollapse() {
this.handleToggleCollapse();
}
}
if (!customElements.get('apg-window-splitter')) {
customElements.define('apg-window-splitter', ApgWindowSplitter);
}
</script> Usage
---
import WindowSplitter from './WindowSplitter.astro';
---
<div class="layout">
<div id="primary-pane">
Primary Content
</div>
<WindowSplitter
primaryPaneId="primary-pane"
secondaryPaneId="secondary-pane"
position={50}
min={20}
max={80}
step={5}
aria-label="Resize panels"
/>
<div id="secondary-pane">
Secondary Content
</div>
</div> API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
primaryPaneId | string | required | ID of primary pane |
secondaryPaneId | string | - | ID of secondary pane |
position | number | 50 | Initial position (%) |
collapsed | boolean | false | Start collapsed |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Splitter orientation |
disabled | boolean | false | Disabled state |
readonly | boolean | false | Readonly state |
Custom Element (apg-window-splitter)
The Astro component renders an <apg-window-splitter> Web Component. Configuration
is passed via data-* attributes and the component handles all keyboard and pointer
interaction client-side.
| Attribute | Description |
|---|---|
data-primary-pane-id | ID of primary pane for aria-controls |
data-secondary-pane-id | ID of secondary pane |
data-position | Initial position percentage |
data-min / data-max | Min/max position values |
data-step / data-large-step | Keyboard step sizes |
data-orientation | 'horizontal' or 'vertical' |
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
/**
* WindowSplitter Web Component Tests
*
* Unit tests for the Web Component class.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('WindowSplitter (Web Component)', () => {
let container: HTMLElement;
// Web Component class extracted for testing
class TestApgWindowSplitter extends HTMLElement {
private separator: HTMLElement | null = null;
private containerEl: HTMLElement | null = null;
private isDragging = false;
private previousPosition: number | null = null;
private collapsed = false;
connectedCallback() {
this.separator = this.querySelector('[role="separator"]');
this.containerEl = this.querySelector('.apg-window-splitter');
// Initialize state from data attributes
this.collapsed = this.dataset.collapsed === 'true';
if (!this.collapsed) {
this.previousPosition = this.currentPosition;
}
if (this.separator) {
this.separator.addEventListener('keydown', this.handleKeyDown.bind(this));
}
}
private get min(): number {
return Number(this.dataset.min) || 10;
}
private get max(): number {
return Number(this.dataset.max) || 90;
}
private get step(): number {
return Number(this.dataset.step) || 5;
}
private get largeStep(): number {
return Number(this.dataset.largeStep) || 10;
}
private get orientation(): string {
return this.dataset.orientation || 'horizontal';
}
private get isHorizontal(): boolean {
return this.orientation === 'horizontal';
}
private get isVertical(): boolean {
return this.orientation === 'vertical';
}
private get textDir(): string | undefined {
return this.dataset.dir;
}
private get isRTL(): boolean {
if (this.textDir === 'rtl') return true;
if (this.textDir === 'ltr') return false;
return document.dir === 'rtl';
}
private get isCollapsible(): boolean {
return this.dataset.collapsible !== 'false';
}
private get isDisabled(): boolean {
return this.dataset.disabled === 'true';
}
private get isReadonly(): boolean {
return this.dataset.readonly === 'true';
}
private get defaultPosition(): number {
return Number(this.dataset.defaultPosition) || 50;
}
private get expandedPosition(): number | undefined {
const val = this.dataset.expandedPosition;
return val !== undefined ? Number(val) : undefined;
}
private get currentPosition(): number {
return Number(this.separator?.getAttribute('aria-valuenow')) || 0;
}
private clamp(value: number): number {
return Math.min(this.max, Math.max(this.min, value));
}
private updatePosition(newPosition: number) {
if (!this.separator || this.isDisabled) return;
const clampedPosition = this.clamp(newPosition);
const currentPosition = this.currentPosition;
if (clampedPosition === currentPosition) return;
this.separator.setAttribute('aria-valuenow', String(clampedPosition));
if (this.containerEl) {
this.containerEl.style.setProperty('--splitter-position', `${clampedPosition}%`);
}
this.dispatchEvent(
new CustomEvent('positionchange', {
detail: { position: clampedPosition },
bubbles: true,
})
);
}
private handleToggleCollapse() {
if (!this.isCollapsible || this.isDisabled || this.isReadonly) return;
if (!this.separator) return;
if (this.collapsed) {
// Expand
const restorePosition =
this.previousPosition ?? this.expandedPosition ?? this.defaultPosition ?? 50;
const clampedRestore = this.clamp(restorePosition);
this.dispatchEvent(
new CustomEvent('collapsedchange', {
detail: { collapsed: false, previousPosition: this.currentPosition },
bubbles: true,
})
);
this.collapsed = false;
this.separator.setAttribute('aria-valuenow', String(clampedRestore));
if (this.containerEl) {
this.containerEl.style.setProperty('--splitter-position', `${clampedRestore}%`);
}
this.dispatchEvent(
new CustomEvent('positionchange', {
detail: { position: clampedRestore },
bubbles: true,
})
);
} else {
// Collapse
this.previousPosition = this.currentPosition;
this.dispatchEvent(
new CustomEvent('collapsedchange', {
detail: { collapsed: true, previousPosition: this.currentPosition },
bubbles: true,
})
);
this.collapsed = true;
this.separator.setAttribute('aria-valuenow', '0');
if (this.containerEl) {
this.containerEl.style.setProperty('--splitter-position', '0%');
}
this.dispatchEvent(
new CustomEvent('positionchange', {
detail: { position: 0, sizeInPx: 0 },
bubbles: true,
})
);
}
}
private handleKeyDown(event: KeyboardEvent) {
if (this.isDisabled || this.isReadonly) return;
const hasShift = event.shiftKey;
const currentStep = hasShift ? this.largeStep : this.step;
let delta = 0;
let handled = false;
switch (event.key) {
case 'ArrowRight':
if (!this.isHorizontal) break;
delta = this.isRTL ? -currentStep : currentStep;
handled = true;
break;
case 'ArrowLeft':
if (!this.isHorizontal) break;
delta = this.isRTL ? currentStep : -currentStep;
handled = true;
break;
case 'ArrowUp':
if (!this.isVertical) break;
delta = currentStep;
handled = true;
break;
case 'ArrowDown':
if (!this.isVertical) break;
delta = -currentStep;
handled = true;
break;
case 'Enter':
this.handleToggleCollapse();
handled = true;
break;
case 'Home':
this.updatePosition(this.min);
handled = true;
break;
case 'End':
this.updatePosition(this.max);
handled = true;
break;
}
if (handled) {
event.preventDefault();
if (delta !== 0) {
this.updatePosition(this.currentPosition + delta);
}
}
}
setPosition(newPosition: number) {
this.updatePosition(newPosition);
}
toggleCollapse() {
this.handleToggleCollapse();
}
// Expose for testing
get _separator() {
return this.separator;
}
get _containerEl() {
return this.containerEl;
}
get _collapsed() {
return this.collapsed;
}
}
function createSplitterHTML(
options: {
position?: number;
min?: number;
max?: number;
step?: number;
largeStep?: number;
orientation?: 'horizontal' | 'vertical';
dir?: 'ltr' | 'rtl';
collapsible?: boolean;
disabled?: boolean;
readonly?: boolean;
collapsed?: boolean;
expandedPosition?: number;
ariaLabel?: string;
primaryPaneId?: string;
secondaryPaneId?: string;
} = {}
): string {
const {
position = 50,
min = 10,
max = 90,
step = 5,
largeStep = 10,
orientation = 'horizontal',
dir,
collapsible = true,
disabled = false,
readonly = false,
collapsed = false,
expandedPosition,
ariaLabel = 'Resize panels',
primaryPaneId = 'primary',
secondaryPaneId,
} = options;
const initialPosition = collapsed ? 0 : Math.min(max, Math.max(min, position));
const isVertical = orientation === 'vertical';
const ariaControls = secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId;
return `
<apg-window-splitter
data-min="${min}"
data-max="${max}"
data-step="${step}"
data-large-step="${largeStep}"
data-orientation="${orientation}"
${dir ? `data-dir="${dir}"` : ''}
data-collapsible="${collapsible}"
data-disabled="${disabled}"
data-readonly="${readonly}"
data-default-position="${position}"
${expandedPosition !== undefined ? `data-expanded-position="${expandedPosition}"` : ''}
data-collapsed="${collapsed}"
>
<div
class="apg-window-splitter ${isVertical ? 'apg-window-splitter--vertical' : ''} ${disabled ? 'apg-window-splitter--disabled' : ''}"
style="--splitter-position: ${initialPosition}%"
>
<div
role="separator"
tabindex="${disabled ? -1 : 0}"
aria-valuenow="${initialPosition}"
aria-valuemin="${min}"
aria-valuemax="${max}"
aria-controls="${ariaControls}"
${isVertical ? 'aria-orientation="vertical"' : ''}
${disabled ? 'aria-disabled="true"' : ''}
aria-label="${ariaLabel}"
class="apg-window-splitter__separator"
></div>
</div>
</apg-window-splitter>
`;
}
beforeEach(() => {
// Register custom element if not registered
if (!customElements.get('apg-window-splitter')) {
customElements.define('apg-window-splitter', TestApgWindowSplitter);
}
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
// Helper to dispatch keyboard events
function pressKey(element: HTMLElement, key: string, options: { shiftKey?: boolean } = {}) {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...options,
});
element.dispatchEvent(event);
}
describe('ARIA Attributes', () => {
it('has role="separator"', () => {
container.innerHTML = createSplitterHTML();
const separator = container.querySelector('[role="separator"]');
expect(separator).toBeTruthy();
});
it('has aria-valuenow set to initial position', () => {
container.innerHTML = createSplitterHTML({ position: 30 });
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuenow')).toBe('30');
});
it('has aria-valuenow="0" when collapsed', () => {
container.innerHTML = createSplitterHTML({ collapsed: true });
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuenow')).toBe('0');
});
it('has aria-valuemin set', () => {
container.innerHTML = createSplitterHTML({ min: 5 });
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuemin')).toBe('5');
});
it('has aria-valuemax set', () => {
container.innerHTML = createSplitterHTML({ max: 95 });
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuemax')).toBe('95');
});
it('has aria-controls referencing primary pane', () => {
container.innerHTML = createSplitterHTML({ primaryPaneId: 'main-panel' });
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-controls')).toBe('main-panel');
});
it('has aria-controls referencing both panes', () => {
container.innerHTML = createSplitterHTML({
primaryPaneId: 'primary',
secondaryPaneId: 'secondary',
});
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-controls')).toBe('primary secondary');
});
it('has aria-disabled="true" when disabled', () => {
container.innerHTML = createSplitterHTML({ disabled: true });
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-disabled')).toBe('true');
});
// Note: aria-readonly is not a valid attribute for role="separator"
// Readonly behavior is enforced via JavaScript only
it('has aria-orientation="vertical" for vertical splitter', () => {
container.innerHTML = createSplitterHTML({ orientation: 'vertical' });
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-orientation')).toBe('vertical');
});
});
describe('Keyboard Interaction - Horizontal', () => {
it('increases value by step on ArrowRight', async () => {
container.innerHTML = createSplitterHTML({ position: 50 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowRight');
expect(separator.getAttribute('aria-valuenow')).toBe('55');
});
it('decreases value by step on ArrowLeft', async () => {
container.innerHTML = createSplitterHTML({ position: 50 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowLeft');
expect(separator.getAttribute('aria-valuenow')).toBe('45');
});
it('increases value by largeStep on Shift+ArrowRight', async () => {
container.innerHTML = createSplitterHTML({ position: 50, largeStep: 10 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowRight', { shiftKey: true });
expect(separator.getAttribute('aria-valuenow')).toBe('60');
});
it('ignores ArrowUp on horizontal splitter', async () => {
container.innerHTML = createSplitterHTML({ position: 50 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowUp');
expect(separator.getAttribute('aria-valuenow')).toBe('50');
});
it('ignores ArrowDown on horizontal splitter', async () => {
container.innerHTML = createSplitterHTML({ position: 50 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowDown');
expect(separator.getAttribute('aria-valuenow')).toBe('50');
});
});
describe('Keyboard Interaction - Vertical', () => {
it('increases value by step on ArrowUp', async () => {
container.innerHTML = createSplitterHTML({
position: 50,
orientation: 'vertical',
});
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowUp');
expect(separator.getAttribute('aria-valuenow')).toBe('55');
});
it('decreases value by step on ArrowDown', async () => {
container.innerHTML = createSplitterHTML({
position: 50,
orientation: 'vertical',
});
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowDown');
expect(separator.getAttribute('aria-valuenow')).toBe('45');
});
it('ignores ArrowLeft on vertical splitter', async () => {
container.innerHTML = createSplitterHTML({
position: 50,
orientation: 'vertical',
});
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowLeft');
expect(separator.getAttribute('aria-valuenow')).toBe('50');
});
it('ignores ArrowRight on vertical splitter', async () => {
container.innerHTML = createSplitterHTML({
position: 50,
orientation: 'vertical',
});
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowRight');
expect(separator.getAttribute('aria-valuenow')).toBe('50');
});
});
describe('Keyboard Interaction - Collapse/Expand', () => {
it('collapses on Enter', async () => {
container.innerHTML = createSplitterHTML({ position: 50 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'Enter');
expect(separator.getAttribute('aria-valuenow')).toBe('0');
});
it('restores previous value on Enter after collapse', async () => {
container.innerHTML = createSplitterHTML({ position: 50 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'Enter'); // Collapse
pressKey(separator, 'Enter'); // Expand
expect(separator.getAttribute('aria-valuenow')).toBe('50');
});
it('expands to expandedPosition when initially collapsed', async () => {
container.innerHTML = createSplitterHTML({
collapsed: true,
expandedPosition: 30,
});
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'Enter');
expect(separator.getAttribute('aria-valuenow')).toBe('30');
});
it('does not collapse when collapsible is false', async () => {
container.innerHTML = createSplitterHTML({
position: 50,
collapsible: false,
});
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'Enter');
expect(separator.getAttribute('aria-valuenow')).toBe('50');
});
});
describe('Keyboard Interaction - Home/End', () => {
it('sets min value on Home', async () => {
container.innerHTML = createSplitterHTML({ position: 50, min: 10 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'Home');
expect(separator.getAttribute('aria-valuenow')).toBe('10');
});
it('sets max value on End', async () => {
container.innerHTML = createSplitterHTML({ position: 50, max: 90 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'End');
expect(separator.getAttribute('aria-valuenow')).toBe('90');
});
});
describe('Keyboard Interaction - RTL', () => {
it('ArrowLeft increases value in RTL mode', async () => {
container.innerHTML = createSplitterHTML({ position: 50, dir: 'rtl' });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowLeft');
expect(separator.getAttribute('aria-valuenow')).toBe('55');
});
it('ArrowRight decreases value in RTL mode', async () => {
container.innerHTML = createSplitterHTML({ position: 50, dir: 'rtl' });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowRight');
expect(separator.getAttribute('aria-valuenow')).toBe('45');
});
});
describe('Keyboard Interaction - Disabled/Readonly', () => {
it('does not change value when disabled', async () => {
container.innerHTML = createSplitterHTML({ position: 50, disabled: true });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowRight');
expect(separator.getAttribute('aria-valuenow')).toBe('50');
});
it('does not change value when readonly', async () => {
container.innerHTML = createSplitterHTML({ position: 50, readonly: true });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowRight');
expect(separator.getAttribute('aria-valuenow')).toBe('50');
});
it('does not collapse when disabled', async () => {
container.innerHTML = createSplitterHTML({ position: 50, disabled: true });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'Enter');
expect(separator.getAttribute('aria-valuenow')).toBe('50');
});
it('does not collapse when readonly', async () => {
container.innerHTML = createSplitterHTML({ position: 50, readonly: true });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'Enter');
expect(separator.getAttribute('aria-valuenow')).toBe('50');
});
});
describe('Focus Management', () => {
it('has tabindex="0" on separator', () => {
container.innerHTML = createSplitterHTML();
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('tabindex')).toBe('0');
});
it('has tabindex="-1" when disabled', () => {
container.innerHTML = createSplitterHTML({ disabled: true });
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('tabindex')).toBe('-1');
});
it('has tabindex="0" when readonly', () => {
container.innerHTML = createSplitterHTML({ readonly: true });
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('tabindex')).toBe('0');
});
});
describe('Edge Cases', () => {
it('does not exceed max on ArrowRight', async () => {
container.innerHTML = createSplitterHTML({
position: 88,
max: 90,
step: 5,
});
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowRight');
expect(separator.getAttribute('aria-valuenow')).toBe('90');
});
it('does not go below min on ArrowLeft', async () => {
container.innerHTML = createSplitterHTML({
position: 12,
min: 10,
step: 5,
});
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const separator = element._separator!;
pressKey(separator, 'ArrowLeft');
expect(separator.getAttribute('aria-valuenow')).toBe('10');
});
it('clamps defaultPosition to min', () => {
container.innerHTML = createSplitterHTML({ position: 5, min: 10 });
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuenow')).toBe('10');
});
it('clamps defaultPosition to max', () => {
container.innerHTML = createSplitterHTML({ position: 95, max: 90 });
const separator = container.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuenow')).toBe('90');
});
});
describe('Events', () => {
it('dispatches positionchange on keyboard interaction', async () => {
container.innerHTML = createSplitterHTML({ position: 50 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const handler = vi.fn();
element.addEventListener('positionchange', handler);
const separator = element._separator!;
pressKey(separator, 'ArrowRight');
expect(handler).toHaveBeenCalled();
expect(handler.mock.calls[0][0].detail.position).toBe(55);
});
it('dispatches collapsedchange on collapse', async () => {
container.innerHTML = createSplitterHTML({ position: 50 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const handler = vi.fn();
element.addEventListener('collapsedchange', handler);
const separator = element._separator!;
pressKey(separator, 'Enter');
expect(handler).toHaveBeenCalled();
expect(handler.mock.calls[0][0].detail.collapsed).toBe(true);
expect(handler.mock.calls[0][0].detail.previousPosition).toBe(50);
});
it('dispatches collapsedchange on expand', async () => {
container.innerHTML = createSplitterHTML({ collapsed: true });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
const handler = vi.fn();
element.addEventListener('collapsedchange', handler);
const separator = element._separator!;
pressKey(separator, 'Enter');
expect(handler).toHaveBeenCalled();
expect(handler.mock.calls[0][0].detail.collapsed).toBe(false);
});
});
describe('Public API', () => {
it('can set position programmatically', async () => {
container.innerHTML = createSplitterHTML({ position: 50 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
element.setPosition(70);
const separator = element._separator!;
expect(separator.getAttribute('aria-valuenow')).toBe('70');
});
it('can toggle collapse programmatically', async () => {
container.innerHTML = createSplitterHTML({ position: 50 });
const element = container.querySelector('apg-window-splitter') as TestApgWindowSplitter;
await customElements.whenDefined('apg-window-splitter');
element.connectedCallback();
element.toggleCollapse();
const separator = element._separator!;
expect(separator.getAttribute('aria-valuenow')).toBe('0');
});
});
}); 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