APG Patterns
English
English

Window Splitter

2つのペイン間で移動可能なセパレーター。ユーザーが各ペインの相対的なサイズを変更できます。IDE、ファイルブラウザ、リサイズ可能なレイアウトで使用されます。

デモ

矢印キーでスプリッターを移動します。Enter キーで折りたたみ/展開します。Shift+矢印キーで大きなステップで移動します。Home/End キーで最小/最大位置に移動します。

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

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

ロール対象要素説明
separatorスプリッター要素ペインサイズを制御するフォーカス可能なセパレーター

WAI-ARIA プロパティ

aria-valuenow

プライマリペインのサイズ(パーセンテージ)

0-100
必須
はい

aria-valuemin

最小値(デフォルト: 10)

number
必須
はい

aria-valuemax

最大値(デフォルト: 90)

number
必須
はい

aria-controls

プライマリペインのID(+ セカンダリペインのIDは任意)

ID reference(s)
必須
はい

aria-label

アクセシブルな名前

string
必須
条件付き(aria-labelledbyがない場合は必須)

aria-labelledby

表示されるラベル要素への参照

ID reference
必須
条件付き(aria-labelがない場合は必須)

aria-orientation

デフォルト: horizontal(左右分割)

horizontal | vertical
必須
いいえ

aria-disabled

無効状態

true | false
必須
いいえ

WAI-ARIA ステート

aria-valuenow

対象要素
separator要素
0-100(0 = 折り畳み、50 = 半分、100 = 完全展開)
必須
はい
変更トリガー

矢印キー、Home/End、Enter(折り畳み/展開)、ポインタードラッグ

キーボードサポート

キーアクション
Arrow Right / Arrow Left水平スプリッターを移動(増加/減少)
Arrow Up / Arrow Down垂直スプリッターを移動(増加/減少)
Shift + Arrow大きなステップで移動(デフォルト: 10%)
Home最小位置に移動
End最大位置に移動
Enterプライマリペインの折り畳み/展開を切り替え
  • 矢印キーは向きに基づいて方向が制限されます。水平スプリッターはLeft/Rightのみに、垂直スプリッターはUp/Downのみに応答します。
  • RTLモードでは、水平スプリッターでArrowLeftが増加、ArrowRightが減少になります。
  • aria-readonlyはrole=“separator”には有効ではありません。読み取り専用の動作はJavaScriptのみで強制する必要があります。

フォーカス管理

イベント振る舞い
Tabスプリッターは通常のタブ順序でフォーカスを受け取る
無効時スプリッターはフォーカス不可(tabindex="-1"
読み取り専用時スプリッターはフォーカス可能だが操作不可
折り畳み/展開後フォーカスはスプリッターに残る

参考資料

ソースコード

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>

使い方

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

プロパティ デフォルト 説明
primaryPaneId string required プライマリペインのID(aria-controls用)
secondaryPaneId string - セカンダリペインのID(任意)
position number 50 初期位置(パーセンテージ 0-100)
collapsed boolean false 折り畳み状態で開始
orientation 'horizontal' | 'vertical' 'horizontal' スプリッターの向き
disabled boolean false 無効状態
readonly boolean false 読み取り専用状態
Astroコンポーネントは <apg-window-splitter> Web Componentをレンダリングします。設定は data-* 属性を通じて渡され、コンポーネントはすべてのキーボードとポインターのインタラクションをクライアントサイドで処理します。

Custom Events

イベント Detail 説明
window-splitter:position-change { position: number, sizeInPx: number } 位置変更時に発火
window-splitter:collapsed-change { collapsed: boolean, previousPosition: number } 折り畳み状態変更時に発火

テスト

テストは、ARIA構造、キーボードナビゲーション、フォーカス管理、ポインターインタラクションのAPG準拠を検証します。Window Splitterコンポーネントは2層のテスト戦略を使用します。

テスト戦略

ユニットテスト(Container API / Testing Library)

コンポーネントのHTML出力と基本的なインタラクションを検証します。これらのテストは正しいテンプレートレンダリングとARIA属性を確認します。

  • ARIA構造(role、aria-valuenow、aria-controls)
  • キーボードインタラクション(矢印キー、Home/End、Enter)
  • 折り畳み/展開機能
  • RTLサポート
  • 無効/読み取り専用状態

E2Eテスト(Playwright)

ポインターインタラクションを含む実際のブラウザ環境でコンポーネントの動作を検証します。

  • ポインタードラッグでリサイズ
  • Tabナビゲーション全体のフォーカス管理
  • フレームワーク間の一貫性
  • 視覚状態(CSSカスタムプロパティの更新)

テストカテゴリ

高優先度: ARIA構造

テスト 説明
role="separator" スプリッターにseparatorロールが設定されている
aria-valuenow プライマリペインのサイズ(パーセンテージ 0-100)
aria-valuemin/max 最小値と最大値が設定されている
aria-controls プライマリ(および任意のセカンダリ)ペインを参照
aria-orientation 垂直スプリッターの場合は"vertical"に設定
aria-disabled 無効時は"true"に設定

高優先度: キーボードインタラクション

テスト 説明
ArrowRight/Left 水平スプリッターを移動(増加/減少)
ArrowUp/Down 垂直スプリッターを移動(増加/減少)
Direction restriction 誤った方向のキーは効果なし
Shift+Arrow 大きなステップで移動
Home/End 最小/最大位置に移動
Enter (collapse) 0に折り畳み
Enter (expand) 以前の位置を復元
RTL support RTLモードでArrowLeft/Rightが反転

高優先度: フォーカス管理

テスト 説明
tabindex="0" スプリッターがフォーカス可能
tabindex="-1" 無効なスプリッターはフォーカス不可
readonly focusable 読み取り専用スプリッターはフォーカス可能だが操作不可
Focus after collapse フォーカスはスプリッターに残る

中優先度: ポインターインタラクション

テスト 説明
Drag to resize ドラッグ中に位置が更新される
Focus on click クリックでスプリッターにフォーカス
Disabled no response 無効なスプリッターはポインターを無視
Readonly no response 読み取り専用スプリッターはポインターを無視

中優先度: アクセシビリティ

テスト 説明
axe violations WCAG 2.1 AA違反なし
Collapsed state 折り畳み時に違反なし
Disabled state 無効時に違反なし

テストの実行

ユニットテスト

# 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テスト

# 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');
    });
  });
});

リソース