APG Patterns
日本語 GitHub
日本語 GitHub

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.

🤖 AI Implementation Guide

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.

Keyboard Navigation
/
Move horizontal splitter
/
Move vertical splitter
Shift + Arrow
Move by large step
Home / End
Move to min/max
Enter
Collapse/Expand

Horizontal Splitter

Primary Pane
Secondary Pane

Vertical Splitter

Primary Pane
Secondary Pane

Disabled Splitter

Primary Pane
Secondary Pane

Readonly Splitter

Primary Pane
Secondary Pane

Initially Collapsed

Primary Pane
Secondary Pane

Open demo only →

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 Accessible name (required if no aria-labelledby)
aria-labelledby separator ID reference Conditional 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

WindowSplitter.astro
---
/**
 * 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

Example
---
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
WindowSplitter.test.astro.ts
/**
 * 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