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
---
/**
* 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>
{
!disabled && !readonly && (
<div
class="apg-window-splitter__popup"
role="group"
aria-label={`Adjust ${ariaLabel || ''}`}
>
<button
type="button"
class="apg-window-splitter__popup-button"
tabindex="-1"
aria-label={`Collapse ${ariaLabel || ''}`}
data-adjust="collapse"
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style={`transform: translate(${isVertical ? '0px, -1px' : '-1px, 0px'})`}
>
<path d={isVertical ? 'M2 9L6 5L10 9M2 5L6 1L10 5' : 'M5 2L1 6L5 10M9 2L5 6L9 10'} />
</svg>
</button>
<button
type="button"
class="apg-window-splitter__popup-button"
tabindex="-1"
aria-label={`Shrink ${ariaLabel || ''}`}
data-adjust="decrease"
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style={`transform: translate(${isVertical ? '0px, -1px' : '-1px, 0px'})`}
>
<path d={isVertical ? 'M2 8L6 4L10 8' : 'M8 2L4 6L8 10'} />
</svg>
</button>
<button
type="button"
class="apg-window-splitter__popup-button"
tabindex="-1"
aria-label={`Expand ${ariaLabel || ''}`}
data-adjust="increase"
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style={`transform: translate(${isVertical ? '0px, 1px' : '1px, 0px'})`}
>
<path d={isVertical ? 'M2 4L6 8L10 4' : 'M4 2L8 6L4 10'} />
</svg>
</button>
<button
type="button"
class="apg-window-splitter__popup-button"
tabindex="-1"
aria-label={`Expand ${ariaLabel || ''} to maximum`}
data-adjust="maximize"
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style={`transform: translate(${isVertical ? '0px, 1px' : '1px, 0px'})`}
>
<path
d={isVertical ? 'M2 3L6 7L10 3M2 7L6 11L10 7' : 'M3 2L7 6L3 10M7 2L11 6L7 10'}
/>
</svg>
</button>
</div>
)
}
</div>
</apg-window-splitter>
<script>
const HOVER_DELAY = 300;
const DISMISS_DELAY = 300;
const ACTIVE_SETTLE_DELAY = 500;
const TAP_DISTANCE_THRESHOLD = 5;
const POPUP_OFFSET = 8;
class ApgWindowSplitter extends HTMLElement {
private separator: HTMLElement | null = null;
private container: HTMLElement | null = null;
private popup: HTMLElement | null = null;
private isDragging = false;
private previousPosition: number | null = null;
private collapsed = false;
private popupState: 'hidden' | 'showing' | 'active' = 'hidden';
private popupPos: { x: number; y: number } | null = null;
private hoverTimer: ReturnType<typeof setTimeout> | null = null;
private dismissTimer: ReturnType<typeof setTimeout> | null = null;
private activeSettleTimer: ReturnType<typeof setTimeout> | null = null;
private pointerStart: { x: number; y: number } | null = null;
private isMouseOverPopup = false;
private hoverPos: { x: number; y: number } | null = null;
// 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);
private boundHandleSeparatorMouseEnter = this.handleSeparatorMouseEnter.bind(this);
private boundHandleSeparatorMouseLeave = this.handleSeparatorMouseLeave.bind(this);
private boundHandleSeparatorMouseMove = this.handleSeparatorMouseMove.bind(this);
private boundHandleSeparatorFocus = this.handleSeparatorFocus.bind(this);
private boundHandleSeparatorBlur = this.handleSeparatorBlur.bind(this);
private boundHandlePopupMouseEnter = this.handlePopupMouseEnter.bind(this);
private boundHandlePopupMouseLeave = this.handlePopupMouseLeave.bind(this);
private boundHandlePopupKeyDown = this.handlePopupKeyDown.bind(this);
private boundHandlePopupButtonClick = this.handlePopupButtonClick.bind(this);
private boundHandleOutsidePointerDown = this.handleOutsidePointerDown.bind(this);
connectedCallback() {
this.separator = this.querySelector('[role="separator"]');
this.container = this.querySelector('.apg-window-splitter');
this.popup = this.querySelector('.apg-window-splitter__popup');
// 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);
this.separator.addEventListener('mouseenter', this.boundHandleSeparatorMouseEnter);
this.separator.addEventListener('mouseleave', this.boundHandleSeparatorMouseLeave);
this.separator.addEventListener('mousemove', this.boundHandleSeparatorMouseMove);
this.separator.addEventListener('focus', this.boundHandleSeparatorFocus);
this.separator.addEventListener('blur', this.boundHandleSeparatorBlur);
}
if (this.popup) {
this.popup.addEventListener('mouseenter', this.boundHandlePopupMouseEnter);
this.popup.addEventListener('mouseleave', this.boundHandlePopupMouseLeave);
this.popup.addEventListener('keydown', this.boundHandlePopupKeyDown);
this.popup.addEventListener('click', this.boundHandlePopupButtonClick);
}
// Reflect the initial collapsed/min/max state on the popup buttons.
this.updatePopupButtonStates();
}
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);
this.separator.removeEventListener('mouseenter', this.boundHandleSeparatorMouseEnter);
this.separator.removeEventListener('mouseleave', this.boundHandleSeparatorMouseLeave);
this.separator.removeEventListener('mousemove', this.boundHandleSeparatorMouseMove);
this.separator.removeEventListener('focus', this.boundHandleSeparatorFocus);
this.separator.removeEventListener('blur', this.boundHandleSeparatorBlur);
}
if (this.popup) {
this.popup.removeEventListener('mouseenter', this.boundHandlePopupMouseEnter);
this.popup.removeEventListener('mouseleave', this.boundHandlePopupMouseLeave);
this.popup.removeEventListener('keydown', this.boundHandlePopupKeyDown);
this.popup.removeEventListener('click', this.boundHandlePopupButtonClick);
}
document.removeEventListener('pointerdown', this.boundHandleOutsidePointerDown);
this.clearAllTimers();
}
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 clearAllTimers() {
if (this.hoverTimer) clearTimeout(this.hoverTimer);
if (this.dismissTimer) clearTimeout(this.dismissTimer);
if (this.activeSettleTimer) clearTimeout(this.activeSettleTimer);
this.hoverTimer = null;
this.dismissTimer = null;
this.activeSettleTimer = null;
}
private updatePopupButtonStates() {
if (!this.popup) return;
const pos = this.currentPosition;
const collapseBtn = this.popup.querySelector<HTMLButtonElement>('[data-adjust="collapse"]');
const decreaseBtn = this.popup.querySelector<HTMLButtonElement>('[data-adjust="decrease"]');
const increaseBtn = this.popup.querySelector<HTMLButtonElement>('[data-adjust="increase"]');
const maximizeBtn = this.popup.querySelector<HTMLButtonElement>('[data-adjust="maximize"]');
// While collapsed the splitter sits below `min`, so the shrink direction is
// disabled (already minimal) and the expand direction stays enabled.
const shrinkDisabled = this.collapsed || pos <= this.min;
const expandDisabled = !this.collapsed && pos >= this.max;
if (collapseBtn) {
collapseBtn.setAttribute('aria-disabled', String(shrinkDisabled));
}
if (decreaseBtn) {
decreaseBtn.setAttribute('aria-disabled', String(shrinkDisabled));
}
if (increaseBtn) {
increaseBtn.setAttribute('aria-disabled', String(expandDisabled));
}
if (maximizeBtn) {
maximizeBtn.setAttribute('aria-disabled', String(expandDisabled));
}
}
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);
}
// Update popup button disabled states
this.updatePopupButtonStates();
// Dispatch event
this.dispatchEvent(
new CustomEvent('positionchange', {
detail: { position: clampedPosition, sizeInPx },
bubbles: true,
})
);
}
// Expand from the collapsed state, restoring to the previous/expanded position.
// Shared by the collapse toggle, popup buttons and keyboard handlers.
private expandFromCollapsed() {
if (!this.separator) return;
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.updatePopupButtonStates();
this.dispatchEvent(
new CustomEvent('positionchange', {
detail: { position: clampedRestore, sizeInPx },
bubbles: true,
})
);
}
private handleToggleCollapse() {
if (!this.isCollapsible || this.isDisabled || this.isReadonly) return;
if (!this.separator) return;
if (this.collapsed) {
this.expandFromCollapsed();
return;
}
// 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.updatePopupButtonStates();
this.dispatchEvent(
new CustomEvent('positionchange', {
detail: { position: 0, sizeInPx: 0 },
bubbles: true,
})
);
}
private calcPopupPosition(clientX: number, clientY: number): { x: number; y: number } | null {
if (!this.separator) return null;
const popupWidth = this.popup?.offsetWidth || (this.isVertical ? 34 : 120);
const popupHeight = this.popup?.offsetHeight || (this.isVertical ? 120 : 34);
const vw = window.innerWidth;
const vh = window.innerHeight;
let x: number;
let y: number;
if (this.isHorizontal) {
x = clientX - popupWidth / 2;
const belowY = clientY + POPUP_OFFSET;
const aboveY = clientY - POPUP_OFFSET - popupHeight;
y = belowY + popupHeight <= vh ? belowY : aboveY;
} else {
y = clientY - popupHeight / 2;
const rightX = clientX + POPUP_OFFSET;
const leftX = clientX - POPUP_OFFSET - popupWidth;
x = rightX + popupWidth <= vw ? rightX : leftX;
}
x = Math.min(vw - popupWidth, Math.max(0, x));
y = Math.min(vh - popupHeight, Math.max(0, y));
return { x, y };
}
private showPopup(clientX: number, clientY: number) {
if (this.isDisabled || this.isReadonly || !this.popup) return;
if (this.dismissTimer) {
clearTimeout(this.dismissTimer);
this.dismissTimer = null;
}
const pos = this.calcPopupPosition(clientX, clientY);
if (pos) {
this.popupPos = pos;
this.popup.style.left = `${pos.x}px`;
this.popup.style.top = `${pos.y}px`;
this.popup.style.flexDirection = this.isVertical ? 'column' : 'row';
this.popup.classList.add('apg-window-splitter__popup--visible');
this.popupState = 'showing';
this.updatePopupButtonStates();
}
}
private hidePopup() {
this.clearAllTimers();
document.removeEventListener('pointerdown', this.boundHandleOutsidePointerDown);
this.popupState = 'hidden';
this.popupPos = null;
if (this.popup) {
this.popup.classList.remove('apg-window-splitter__popup--visible');
}
}
private handleOutsidePointerDown(event: PointerEvent) {
if (this.popup && !this.popup.contains(event.target as Node)) {
this.hidePopup();
}
}
private scheduleDismiss() {
if (this.dismissTimer) clearTimeout(this.dismissTimer);
this.dismissTimer = setTimeout(() => {
const hasFocusInside =
this.popup?.contains(document.activeElement) || this.separator === document.activeElement;
if (!hasFocusInside) {
this.hidePopup();
}
}, DISMISS_DELAY);
}
private handleKeyDown(event: KeyboardEvent) {
if (this.isDisabled || this.isReadonly) return;
// Tab to popup buttons when popup is visible
if (event.key === 'Tab' && !event.shiftKey && this.popupState !== 'hidden') {
const firstBtn = this.popup?.querySelector<HTMLButtonElement>('button');
if (firstBtn) {
event.preventDefault();
firstBtn.focus();
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':
// Shrink direction is a no-op while collapsed (already at minimum).
if (!this.collapsed) this.updatePosition(this.min);
handled = true;
break;
case 'End':
if (this.collapsed) {
this.expandFromCollapsed();
} else {
this.updatePosition(this.max);
}
handled = true;
break;
}
if (handled) {
event.preventDefault();
if (delta !== 0) {
if (this.collapsed) {
// Only the expand direction wakes a collapsed splitter; shrink is a no-op.
if (delta > 0) this.expandFromCollapsed();
} else {
this.updatePosition(this.currentPosition + delta);
}
}
}
}
private handlePointerDown(event: PointerEvent) {
if (this.isDisabled || this.isReadonly || !this.separator) return;
event.preventDefault();
this.pointerStart = { x: event.clientX, y: event.clientY };
this.isDragging = false;
if (typeof this.separator.setPointerCapture === 'function') {
this.separator.setPointerCapture(event.pointerId);
}
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
this.hoverTimer = null;
}
this.separator.focus();
}
private handlePointerMove(event: PointerEvent) {
const start = this.pointerStart;
if (!start) return;
if (!this.isDragging) {
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= TAP_DISTANCE_THRESHOLD) {
this.isDragging = true;
if (this.popupState !== 'hidden') this.hidePopup();
} else {
return;
}
}
if (!this.container) return;
// Measure the containing layout (the consumer positions the panes there).
// The visual update is driven solely by the positionchange event, keeping
// the component agnostic of how the consumer renders its panes.
const measureElement = 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;
percent = (y / rect.height) * 100;
}
this.updatePosition(percent);
}
private handlePointerUp(event: PointerEvent) {
if (this.separator && typeof this.separator.releasePointerCapture === 'function') {
try {
this.separator.releasePointerCapture(event.pointerId);
} catch {
// Ignore
}
}
const start = this.pointerStart;
if (start && !this.isDragging) {
this.showPopup(start.x, start.y);
}
this.isDragging = false;
this.pointerStart = null;
}
private handleSeparatorMouseEnter(event: MouseEvent) {
if (this.isDisabled || this.isReadonly || this.isDragging) return;
this.hoverPos = { x: event.clientX, y: event.clientY };
if (this.popupState === 'hidden') {
if (this.hoverTimer) clearTimeout(this.hoverTimer);
this.hoverTimer = setTimeout(() => {
const pos = this.hoverPos;
if (pos) this.showPopup(pos.x, pos.y);
}, HOVER_DELAY);
}
if (this.dismissTimer) {
clearTimeout(this.dismissTimer);
this.dismissTimer = null;
}
}
private handleSeparatorMouseLeave() {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
this.hoverTimer = null;
}
if (this.popupState !== 'hidden') this.scheduleDismiss();
}
private handleSeparatorMouseMove(event: MouseEvent) {
this.hoverPos = { x: event.clientX, y: event.clientY };
}
private handleSeparatorFocus() {
if (this.isDisabled || this.isReadonly) return;
if (this.popupState === 'hidden') {
if (this.separator) {
const rect = this.separator.getBoundingClientRect();
this.showPopup(rect.left + rect.width / 2, rect.top + rect.height / 2);
}
}
if (this.dismissTimer) {
clearTimeout(this.dismissTimer);
this.dismissTimer = null;
}
}
private handleSeparatorBlur() {
if (this.popupState !== 'hidden') {
setTimeout(() => {
if (
!this.popup?.contains(document.activeElement) &&
this.separator !== document.activeElement
) {
this.scheduleDismiss();
}
}, 0);
}
}
private handlePopupMouseEnter() {
this.isMouseOverPopup = true;
if (this.dismissTimer) {
clearTimeout(this.dismissTimer);
this.dismissTimer = null;
}
}
private handlePopupMouseLeave() {
this.isMouseOverPopup = false;
if (this.popupState !== 'hidden') this.scheduleDismiss();
}
private handlePopupKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
this.hidePopup();
this.separator?.focus();
}
if (event.key === 'Tab' && event.shiftKey) {
event.preventDefault();
this.hidePopup();
this.separator?.focus();
}
}
// Mark the popup as actively adjusting, then settle back to the showing state.
private markPopupActive() {
if (this.popupState !== 'active') {
this.popupState = 'active';
document.addEventListener('pointerdown', this.boundHandleOutsidePointerDown);
}
if (this.activeSettleTimer) clearTimeout(this.activeSettleTimer);
this.activeSettleTimer = setTimeout(() => {
if (this.popupState === 'active') {
if (!this.isMouseOverPopup) {
const pos = this.hoverPos;
if (pos && this.popup) {
const newPopupPos = this.calcPopupPosition(pos.x, pos.y);
if (newPopupPos) {
this.popup.style.left = `${newPopupPos.x}px`;
this.popup.style.top = `${newPopupPos.y}px`;
}
}
}
this.popupState = 'showing';
}
}, ACTIVE_SETTLE_DELAY);
}
private handlePopupButtonClick(event: Event) {
const target = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-adjust]');
if (!target) return;
if (this.isDisabled || this.isReadonly) return;
if (target.getAttribute('aria-disabled') === 'true') return;
const adjustType = target.dataset.adjust;
if (this.collapsed) {
// Shrink direction is a no-op while collapsed (already at minimum).
if (adjustType !== 'increase' && adjustType !== 'maximize') return;
this.expandFromCollapsed();
if (adjustType === 'maximize') this.updatePosition(this.max);
this.markPopupActive();
return;
}
let targetPos: number;
switch (adjustType) {
case 'collapse':
targetPos = this.min;
break;
case 'decrease':
targetPos = this.currentPosition - this.step;
break;
case 'increase':
targetPos = this.currentPosition + this.step;
break;
case 'maximize':
targetPos = this.max;
break;
default:
return;
}
this.updatePosition(targetPos);
this.markPopupActive();
}
// 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
| 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 |
<apg-window-splitter> Web Component. Configuration is passed via data-* attributes and the component handles all keyboard and pointer interaction client-side.Custom Events
| Event | Detail | Description |
|---|---|---|
windowsplitter:position-change | { position: number, sizeInPx: number } | Fired when position changes |
windowsplitter:collapsed-change | { collapsed: boolean, previousPosition: number } | Fired 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/windowsplitter/
# 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:astroE2E Tests
# Run all Window Splitter E2E tests
npm run test:e2e -- windowsplitter.spec.ts
# Run in UI mode
npm run test:e2e:ui -- windowsplitter.spec.tsTesting 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
See the Testing Strategy guide for details.
/**
* WindowSplitter Astro Component Tests using Container API
*
* These tests verify the initial server-rendered HTML of WindowSplitter.astro.
* Web Component behavior (keyboard, collapse/expand, RTL, pointer, popup,
* positionchange/collapsedchange events and the setPosition/toggleCollapse
* methods) is covered by E2E tests in e2e/windowsplitter.spec.ts.
*
* @see https://docs.astro.build/en/reference/container-reference/
*/
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import WindowSplitter from './WindowSplitter.astro';
describe('WindowSplitter (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
async function renderSplitter(props: Record<string, unknown>): Promise<Document> {
const html = await container.renderToString(WindowSplitter, { props });
return new JSDOM(html).window.document;
}
// 🔴 High Priority: HTML Structure
describe('HTML Structure', () => {
it('renders apg-window-splitter custom element wrapper', async () => {
const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
expect(doc.querySelector('apg-window-splitter')).not.toBeNull();
});
it('renders an element with role="separator"', async () => {
const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
expect(doc.querySelector('[role="separator"]')).not.toBeNull();
});
it('sets the initial position as --splitter-position on the container', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
defaultPosition: 30,
'aria-label': 'Resize',
});
const splitterContainer = doc.querySelector<HTMLElement>('.apg-window-splitter');
expect(splitterContainer?.getAttribute('style')).toBe('--splitter-position: 30%');
});
it('adds the vertical modifier class for a vertical splitter', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
orientation: 'vertical',
'aria-label': 'Resize',
});
const splitterContainer = doc.querySelector('.apg-window-splitter');
expect(splitterContainer?.classList.contains('apg-window-splitter--vertical')).toBe(true);
});
it('adds the disabled modifier class when disabled', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
disabled: true,
'aria-label': 'Resize',
});
const splitterContainer = doc.querySelector('.apg-window-splitter');
expect(splitterContainer?.classList.contains('apg-window-splitter--disabled')).toBe(true);
});
});
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has aria-valuenow set to defaultPosition', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
defaultPosition: 30,
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuenow')).toBe('30');
});
it('has aria-valuenow="50" when no defaultPosition is given', async () => {
const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuenow')).toBe('50');
});
it('has aria-valuenow="0" when defaultCollapsed is true', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
defaultPosition: 30,
defaultCollapsed: true,
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuenow')).toBe('0');
});
it('has aria-valuemin="10" and aria-valuemax="90" by default', async () => {
const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuemin')).toBe('10');
expect(separator?.getAttribute('aria-valuemax')).toBe('90');
});
it('has aria-valuemin set to min', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
min: 5,
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuemin')).toBe('5');
});
it('has aria-valuemax set to max', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
max: 95,
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuemax')).toBe('95');
});
it('has aria-controls referencing the primary pane', async () => {
const doc = await renderSplitter({ primaryPaneId: 'main-panel', 'aria-label': 'Resize' });
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-controls')).toBe('main-panel');
});
it('has aria-controls referencing both panes when secondaryPaneId is provided', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
secondaryPaneId: 'secondary',
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-controls')).toBe('primary secondary');
});
it('has aria-orientation="vertical" for a vertical splitter', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
orientation: 'vertical',
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-orientation')).toBe('vertical');
});
it('does not have aria-orientation for a horizontal splitter', async () => {
const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
const separator = doc.querySelector('[role="separator"]');
expect(separator?.hasAttribute('aria-orientation')).toBe(false);
});
it('has aria-disabled="true" when disabled', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
disabled: true,
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-disabled')).toBe('true');
});
it('does not have aria-disabled when not disabled', async () => {
const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
const separator = doc.querySelector('[role="separator"]');
expect(separator?.hasAttribute('aria-disabled')).toBe(false);
});
// aria-readonly is not a valid attribute for role="separator";
// readonly behavior is enforced by the Web Component only.
it('does not have aria-readonly when readonly', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
readonly: true,
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.hasAttribute('aria-readonly')).toBe(false);
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has aria-label when provided', async () => {
const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize panels' });
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-label')).toBe('Resize panels');
});
it('has aria-labelledby when provided', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
'aria-labelledby': 'splitter-label',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-labelledby')).toBe('splitter-label');
});
it('has aria-describedby when provided', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
'aria-label': 'Resize',
'aria-describedby': 'splitter-help',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-describedby')).toBe('splitter-help');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" on the separator', async () => {
const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Resize' });
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('tabindex')).toBe('0');
});
it('has tabindex="-1" on the separator when disabled', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
disabled: true,
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('tabindex')).toBe('-1');
});
it('has tabindex="0" on the separator when readonly', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
readonly: true,
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('tabindex')).toBe('0');
});
});
// 🟡 Medium Priority: Popup Buttons
describe('Popup Buttons', () => {
it('renders a labelled group of adjust buttons', async () => {
const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Sidebar' });
const group = doc.querySelector('[role="group"]');
const labels = Array.from(group?.querySelectorAll('button') ?? []).map((button) =>
button.getAttribute('aria-label')
);
expect(group?.getAttribute('aria-label')).toBe('Adjust Sidebar');
expect(labels).toEqual([
'Collapse Sidebar',
'Shrink Sidebar',
'Expand Sidebar',
'Expand Sidebar to maximum',
]);
});
it('has tabindex="-1" on the adjust buttons', async () => {
const doc = await renderSplitter({ primaryPaneId: 'primary', 'aria-label': 'Sidebar' });
const tabindexes = Array.from(doc.querySelectorAll('[role="group"] button')).map((button) =>
button.getAttribute('tabindex')
);
expect(tabindexes).toEqual(['-1', '-1', '-1', '-1']);
});
it('does not render the adjust buttons when disabled', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
disabled: true,
'aria-label': 'Sidebar',
});
expect(doc.querySelector('[role="group"]')).toBeNull();
});
it('does not render the adjust buttons when readonly', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
readonly: true,
'aria-label': 'Sidebar',
});
expect(doc.querySelector('[role="group"]')).toBeNull();
});
});
// 🟡 Medium Priority: Initial Position Normalization
describe('Initial Position Normalization', () => {
it('clamps defaultPosition below min to min', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
defaultPosition: 5,
min: 10,
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuenow')).toBe('10');
});
it('clamps defaultPosition above max to max', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
defaultPosition: 95,
max: 90,
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('aria-valuenow')).toBe('90');
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attributes', () => {
it('sets the id attribute on the separator', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
id: 'sidebar-splitter',
'aria-label': 'Resize',
});
expect(doc.querySelector('[role="separator"]')?.id).toBe('sidebar-splitter');
});
it('applies a custom class to the container', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
class: 'custom-class',
'aria-label': 'Resize',
});
const splitterContainer = doc.querySelector('.apg-window-splitter');
expect(splitterContainer?.classList.contains('custom-class')).toBe(true);
});
it('sets data-testid on the separator', async () => {
const doc = await renderSplitter({
primaryPaneId: 'primary',
'data-testid': 'splitter',
'aria-label': 'Resize',
});
const separator = doc.querySelector('[role="separator"]');
expect(separator?.getAttribute('data-testid')).toBe('splitter');
});
});
});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