APG Patterns
English GitHub
English GitHub

Window Splitter

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

🤖 AI 実装ガイド

デモ

矢印キーでスプリッターを移動します。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

Position: 50% | Collapsed: No
Primary Pane
Secondary Pane

Vertical Splitter

Position: 50% | Collapsed: No
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 separator ロール (opens in new tab)

WAI-ARIA プロパティ

属性 対象 必須 説明
aria-valuenow separator 0-100 はい プライマリペインのサイズ(パーセンテージ)
aria-valuemin separator 数値 はい 最小値(デフォルト: 10)
aria-valuemax separator 数値 はい 最大値(デフォルト: 90)
aria-controls separator ID参照 はい プライマリペインのID(+ セカンダリペインID(任意))
aria-label separator 文字列 条件付き アクセシブルな名前(aria-labelledbyがない場合は必須)
aria-labelledby separator ID参照 条件付き 可視ラベル要素への参照
aria-orientation separator "horizontal" | "vertical" いいえ デフォルト: horizontal(左右分割)
aria-disabled separator true | false いいえ 無効状態

注意: aria-readonlyrole="separator" では有効ではありません。 読み取り専用の動作はJavaScriptでのみ制御する必要があります。

WAI-ARIA ステート

aria-valuenow

スプリッターの現在位置(パーセンテージ 0-100)。

対象 separator 要素
0-100(0 = 折り畳み、50 = 半分、100 = 完全展開)
必須 はい
変更トリガー 矢印キー、Home/End、Enter(折り畳み/展開)、ポインタードラッグ
参照 aria-valuenow (opens in new tab)

キーボードサポート

キー 動作
/ 水平スプリッターを移動(増加/減少)
/ 垂直スプリッターを移動(増加/減少)
Shift + 矢印 大きなステップで移動(デフォルト: 10%)
Home 最小位置に移動
End 最大位置に移動
Enter プライマリペインの折り畳み/展開を切り替え

注意: 矢印キーは向きに基づいて制限されます。 水平スプリッターは左/右のみ、垂直スプリッターは上/下のみに応答します。 RTLモードでは、水平スプリッターの場合、左矢印で増加、右矢印で減少します。

ソースコード

WindowSplitter.svelte
<script lang="ts">
  interface WindowSplitterProps {
    /** Primary pane ID (required for aria-controls) */
    primaryPaneId: string;
    /** Secondary pane ID (optional, added to aria-controls) */
    secondaryPaneId?: string;
    /** Initial position as % (0-100, default: 50) */
    defaultPosition?: number;
    /** Initial collapsed state (default: false) */
    defaultCollapsed?: boolean;
    /** Position when expanding from initial collapse */
    expandedPosition?: number;
    /** Minimum position as % (default: 10) */
    min?: number;
    /** Maximum position as % (default: 90) */
    max?: number;
    /** Keyboard step as % (default: 5) */
    step?: number;
    /** Shift+Arrow step as % (default: 10) */
    largeStep?: number;
    /** Splitter orientation (default: horizontal = left-right split) */
    orientation?: 'horizontal' | 'vertical';
    /** Text direction for RTL support */
    dir?: 'ltr' | 'rtl';
    /** Whether pane can be collapsed (default: true) */
    collapsible?: boolean;
    /** Disabled state (not focusable, not operable) */
    disabled?: boolean;
    /** Readonly state (focusable but not operable) */
    readonly?: boolean;
    /** Callback when position changes */
    onpositionchange?: (position: number, sizeInPx: number) => void;
    /** Callback when collapsed state changes */
    oncollapsedchange?: (collapsed: boolean, previousPosition: number) => void;
    [key: string]: unknown;
  }

  let {
    primaryPaneId,
    secondaryPaneId,
    defaultPosition = 50,
    defaultCollapsed = false,
    expandedPosition,
    min = 10,
    max = 90,
    step = 5,
    largeStep = 10,
    orientation = 'horizontal',
    dir,
    collapsible = true,
    disabled = false,
    readonly = false,
    onpositionchange,
    oncollapsedchange,
    ...restProps
  }: WindowSplitterProps = $props();

  // Utility function
  function clamp(value: number, minVal: number, maxVal: number): number {
    return Math.min(maxVal, Math.max(minVal, value));
  }

  // Refs
  let splitterEl: HTMLDivElement | null = null;
  let containerEl: HTMLDivElement | null = null;
  let isDragging = $state(false);

  // State - capture initial prop values (intentionally not reactive to prop changes)
  // Using IIFE to avoid Svelte's state_referenced_locally warning
  const { initPosition, initCollapsed, initPreviousPosition } = (() => {
    const collapsed = defaultCollapsed;
    const pos = collapsed ? 0 : clamp(defaultPosition, min, max);
    const prevPos = collapsed ? null : clamp(defaultPosition, min, max);
    return { initPosition: pos, initCollapsed: collapsed, initPreviousPosition: prevPos };
  })();
  let position = $state(initPosition);
  let collapsed = $state(initCollapsed);
  let previousPosition: number | null = initPreviousPosition;

  // Computed
  const isVertical = $derived(orientation === 'vertical');
  const isHorizontal = $derived(orientation === 'horizontal');

  const isRTL = $derived(
    dir === 'rtl' || (dir !== 'ltr' && typeof document !== 'undefined' && document.dir === 'rtl')
  );

  const ariaControls = $derived(
    secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId
  );

  // Update position and emit
  function updatePosition(newPosition: number) {
    const clampedPosition = clamp(newPosition, min, max);
    if (clampedPosition !== position) {
      position = clampedPosition;

      const sizeInPx = containerEl
        ? (clampedPosition / 100) *
          (isHorizontal ? containerEl.offsetWidth : containerEl.offsetHeight)
        : 0;

      onpositionchange?.(clampedPosition, sizeInPx);
    }
  }

  // Handle collapse/expand
  function handleToggleCollapse() {
    if (!collapsible || disabled || readonly) return;

    if (collapsed) {
      // Expand: restore to previous or fallback
      const restorePosition = previousPosition ?? expandedPosition ?? defaultPosition ?? 50;
      const clampedRestore = clamp(restorePosition, min, max);

      oncollapsedchange?.(false, position);
      collapsed = false;
      position = clampedRestore;

      const sizeInPx = containerEl
        ? (clampedRestore / 100) *
          (isHorizontal ? containerEl.offsetWidth : containerEl.offsetHeight)
        : 0;
      onpositionchange?.(clampedRestore, sizeInPx);
    } else {
      // Collapse: save current position, set to 0
      previousPosition = position;
      oncollapsedchange?.(true, position);
      collapsed = true;
      position = 0;
      onpositionchange?.(0, 0);
    }
  }

  // Keyboard handler
  function handleKeyDown(event: KeyboardEvent) {
    if (disabled || readonly) return;

    const hasShift = event.shiftKey;
    const currentStep = hasShift ? largeStep : step;

    let delta = 0;
    let handled = false;

    switch (event.key) {
      case 'ArrowRight':
        if (!isHorizontal) break;
        delta = isRTL ? -currentStep : currentStep;
        handled = true;
        break;

      case 'ArrowLeft':
        if (!isHorizontal) break;
        delta = isRTL ? currentStep : -currentStep;
        handled = true;
        break;

      case 'ArrowUp':
        if (!isVertical) break;
        delta = currentStep;
        handled = true;
        break;

      case 'ArrowDown':
        if (!isVertical) break;
        delta = -currentStep;
        handled = true;
        break;

      case 'Enter':
        handleToggleCollapse();
        handled = true;
        break;

      case 'Home':
        updatePosition(min);
        handled = true;
        break;

      case 'End':
        updatePosition(max);
        handled = true;
        break;
    }

    if (handled) {
      event.preventDefault();
      if (delta !== 0) {
        updatePosition(position + delta);
      }
    }
  }

  // Pointer handlers
  function handlePointerDown(event: PointerEvent) {
    if (disabled || readonly) return;

    event.preventDefault();
    if (!splitterEl) return;

    if (typeof splitterEl.setPointerCapture === 'function') {
      splitterEl.setPointerCapture(event.pointerId);
    }
    isDragging = true;
    splitterEl.focus();
  }

  function handlePointerMove(event: PointerEvent) {
    if (!isDragging) return;

    if (!containerEl) return;

    // Use demo container for stable measurement if available
    const demoContainer = containerEl.closest(
      '.apg-window-splitter-demo-container'
    ) as HTMLElement | null;
    const measureElement = demoContainer || containerEl.parentElement || containerEl;
    const rect = measureElement.getBoundingClientRect();

    let percent: number;
    if (isHorizontal) {
      const x = event.clientX - rect.left;
      percent = (x / rect.width) * 100;
    } else {
      const y = event.clientY - rect.top;
      // For vertical, y position corresponds to primary pane height
      percent = (y / rect.height) * 100;
    }

    // Clamp the percent to min/max
    const clampedPercent = clamp(percent, min, max);

    // Update CSS variable directly for smooth dragging
    if (demoContainer) {
      demoContainer.style.setProperty('--splitter-position', `${clampedPercent}%`);
    }

    updatePosition(percent);
  }

  function handlePointerUp(event: PointerEvent) {
    if (splitterEl && typeof splitterEl.releasePointerCapture === 'function') {
      try {
        splitterEl.releasePointerCapture(event.pointerId);
      } catch {
        // Ignore
      }
    }
    isDragging = false;
  }
</script>

<div
  bind:this={containerEl}
  class="apg-window-splitter {isVertical ? 'apg-window-splitter--vertical' : ''} {disabled
    ? 'apg-window-splitter--disabled'
    : ''} {restProps.class || ''}"
  style="--splitter-position: {position}%"
>
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
  <!-- role="separator" with aria-valuenow is a focusable widget per WAI-ARIA spec -->
  <div
    bind:this={splitterEl}
    role="separator"
    id={restProps.id}
    tabindex={disabled ? -1 : 0}
    aria-valuenow={position}
    aria-valuemin={min}
    aria-valuemax={max}
    aria-controls={ariaControls}
    aria-orientation={isVertical ? 'vertical' : undefined}
    aria-disabled={disabled ? true : undefined}
    aria-label={restProps['aria-label']}
    aria-labelledby={restProps['aria-labelledby']}
    aria-describedby={restProps['aria-describedby']}
    data-testid={restProps['data-testid']}
    class="apg-window-splitter__separator"
    onkeydown={handleKeyDown}
    onpointerdown={handlePointerDown}
    onpointermove={handlePointerMove}
    onpointerup={handlePointerUp}
  ></div>
</div>

使い方

Example
<script lang="ts">
  import WindowSplitter from './WindowSplitter.svelte';

  function handlePositionChange(event) {
    console.log('Position:', event.detail.position, 'Size:', event.detail.sizeInPx);
  }
</script>

<div class="layout">
  <div id="primary-pane" style="width: var(--splitter-position)">
    Primary Content
  </div>
  <WindowSplitter
    primaryPaneId="primary-pane"
    secondaryPaneId="secondary-pane"
    defaultPosition={50}
    min={20}
    max={80}
    step={5}
    aria-label="Resize panels"
    onpositionchange={handlePositionChange}
  />
  <div id="secondary-pane">
    Secondary Content
  </div>
</div>

API

WindowSplitter Props

プロパティ デフォルト 説明
primaryPaneId string 必須 プライマリペインのID(aria-controls用)
secondaryPaneId string - セカンダリペインのID(任意)
defaultPosition number 50 初期位置(パーセンテージ 0-100)
orientation 'horizontal' | 'vertical' 'horizontal' スプリッターの向き
min / max number 10 / 90 最小/最大位置(%)
disabled / readonly boolean false 無効/読み取り専用状態

Events

イベント 詳細 説明
positionchange { position: number, sizeInPx: number } 位置変更時に発火
collapsedchange { 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構造(Unit + E2E)

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

高優先度: キーボードインタラクション(Unit + E2E)

テスト 説明
ArrowRight/Left 水平スプリッターを移動(増加/減少)
ArrowUp/Down 垂直スプリッターを移動(増加/減少)
方向制限 間違った方向のキーは無効
Shift+Arrow 大きなステップで移動
Home/End 最小/最大位置に移動
Enter (折り畳み) 0に折り畳む
Enter (展開) 以前の位置に復元
RTLサポート RTLモードではArrowLeft/Rightが反転

高優先度: フォーカス管理(Unit + E2E)

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

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

テスト 説明
ドラッグでリサイズ ドラッグ中に位置が更新される
クリックでフォーカス クリックでスプリッターにフォーカス
無効時は無応答 無効なスプリッターはポインターを無視
読み取り専用時は無応答 読み取り専用スプリッターはポインターを無視

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

テスト 説明
axe違反 WCAG 2.1 AA違反なし
折り畳み状態 折り畳み時に違反なし
無効状態 無効時に違反なし

テストの実行

ユニットテスト

# 全てのWindow Splitterユニットテストを実行
npx vitest run src/patterns/window-splitter/

# フレームワーク別テストを実行
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テスト

# 全てのWindow Splitter E2Eテストを実行
npm run test:e2e -- window-splitter.spec.ts

# UIモードで実行
npm run test:e2e:ui -- window-splitter.spec.ts
WindowSplitter.test.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
import WindowSplitter from './WindowSplitter.svelte';
import WindowSplitterWithLabel from './WindowSplitterWithLabel.test.svelte';
import WindowSplitterWithDescribedby from './WindowSplitterWithDescribedby.test.svelte';
import WindowSplitterWithPanes from './WindowSplitterWithPanes.test.svelte';

describe('WindowSplitter (Svelte)', () => {
  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="separator"', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      expect(screen.getByRole('separator')).toBeInTheDocument();
    });

    it('has aria-valuenow set to current position (default: 50)', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('has aria-valuenow set to custom defaultPosition', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 30,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuenow', '30');
    });

    it('has aria-valuemin set (default: 10)', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemin', '10');
    });

    it('has aria-valuemax set (default: 90)', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemax', '90');
    });

    it('has custom aria-valuemin when provided', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', min: 5, 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemin', '5');
    });

    it('has custom aria-valuemax when provided', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', max: 95, 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemax', '95');
    });

    it('has aria-controls referencing primary pane', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'main-panel', 'aria-label': 'Resize panels' },
      });
      expect(screen.getByRole('separator')).toHaveAttribute('aria-controls', 'main-panel');
    });

    it('has aria-controls referencing both panes when secondaryPaneId provided', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          secondaryPaneId: 'secondary',
          'aria-label': 'Resize panels',
        },
      });
      expect(screen.getByRole('separator')).toHaveAttribute('aria-controls', 'primary secondary');
    });

    it('has aria-valuenow="0" when collapsed', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultCollapsed: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuenow', '0');
    });

    it('has aria-disabled="true" when disabled', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          disabled: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-disabled', 'true');
    });

    it('does not have aria-disabled when not disabled', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).not.toHaveAttribute('aria-disabled');
    });

    // Note: aria-readonly is not a valid attribute for role="separator"
    // Readonly behavior is enforced via JavaScript only

    it('clamps defaultPosition to min', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 5,
          min: 10,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuenow', '10');
    });

    it('clamps defaultPosition to max', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 95,
          max: 90,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuenow', '90');
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      expect(screen.getByRole('separator', { name: 'Resize panels' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(WindowSplitterWithLabel);
      expect(screen.getByRole('separator', { name: 'Panel Divider' })).toBeInTheDocument();
    });

    it('supports aria-describedby', () => {
      render(WindowSplitterWithDescribedby);
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-describedby', 'help');
    });
  });

  // 🔴 High Priority: Orientation
  describe('Orientation', () => {
    it('does not have aria-orientation for horizontal splitter (default)', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).not.toHaveAttribute('aria-orientation');
    });

    it('has aria-orientation="vertical" for vertical splitter', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          orientation: 'vertical',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-orientation', 'vertical');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Horizontal
  describe('Keyboard Interaction - Horizontal', () => {
    it('increases value by step on ArrowRight', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '55');
    });

    it('decreases value by step on ArrowLeft', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowLeft}');

      expect(separator).toHaveAttribute('aria-valuenow', '45');
    });

    it('increases value by largeStep on Shift+ArrowRight', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          largeStep: 10,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Shift>}{ArrowRight}{/Shift}');

      expect(separator).toHaveAttribute('aria-valuenow', '60');
    });

    it('decreases value by largeStep on Shift+ArrowLeft', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          largeStep: 10,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Shift>}{ArrowLeft}{/Shift}');

      expect(separator).toHaveAttribute('aria-valuenow', '40');
    });

    it('ignores ArrowUp on horizontal splitter', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowUp}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('ignores ArrowDown on horizontal splitter', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowDown}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Vertical
  describe('Keyboard Interaction - Vertical', () => {
    it('increases value by step on ArrowUp', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          orientation: 'vertical',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowUp}');

      expect(separator).toHaveAttribute('aria-valuenow', '55');
    });

    it('decreases value by step on ArrowDown', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          orientation: 'vertical',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowDown}');

      expect(separator).toHaveAttribute('aria-valuenow', '45');
    });

    it('ignores ArrowLeft on vertical splitter', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          orientation: 'vertical',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowLeft}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('ignores ArrowRight on vertical splitter', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          orientation: 'vertical',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Collapse/Expand
  describe('Keyboard Interaction - Collapse/Expand', () => {
    it('collapses on Enter (aria-valuenow becomes 0)', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}');

      expect(separator).toHaveAttribute('aria-valuenow', '0');
    });

    it('restores previous value on Enter after collapse', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}'); // Collapse
      await user.keyboard('{Enter}'); // Expand

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('expands to expandedPosition when initially collapsed', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultCollapsed: true,
          expandedPosition: 30,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}'); // Expand

      expect(separator).toHaveAttribute('aria-valuenow', '30');
    });

    it('does not collapse when collapsible is false', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          collapsible: false,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('remembers position across multiple collapse/expand cycles', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}'); // 55
      await user.keyboard('{Enter}'); // Collapse → 0
      expect(separator).toHaveAttribute('aria-valuenow', '0');

      await user.keyboard('{Enter}'); // Expand → 55
      expect(separator).toHaveAttribute('aria-valuenow', '55');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Home/End
  describe('Keyboard Interaction - Home/End', () => {
    it('sets min value on Home', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          min: 10,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Home}');

      expect(separator).toHaveAttribute('aria-valuenow', '10');
    });

    it('sets max value on End', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          max: 90,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{End}');

      expect(separator).toHaveAttribute('aria-valuenow', '90');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - RTL
  describe('Keyboard Interaction - RTL', () => {
    it('ArrowLeft increases value in RTL mode (horizontal)', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          dir: 'rtl',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowLeft}');

      expect(separator).toHaveAttribute('aria-valuenow', '55');
    });

    it('ArrowRight decreases value in RTL mode (horizontal)', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          dir: 'rtl',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '45');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Disabled/Readonly
  describe('Keyboard Interaction - Disabled/Readonly', () => {
    it('does not change value when disabled', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          disabled: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      separator.focus();
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('does not change value when readonly', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          readonly: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('does not collapse when disabled', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          disabled: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      separator.focus();
      await user.keyboard('{Enter}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('does not collapse when readonly', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          readonly: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" on separator', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('tabindex', '0');
    });

    it('has tabindex="-1" when disabled', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          disabled: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('tabindex', '-1');
    });

    it('is focusable when readonly', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          readonly: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('tabindex', '0');
    });

    it('can be focused via click', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);

      expect(document.activeElement).toBe(separator);
    });
  });

  // 🟡 Medium Priority: Pointer Interaction
  describe('Pointer Interaction', () => {
    it('focuses separator on pointer down', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

      await user.pointer({ target: separator, keys: '[MouseLeft>]' });

      expect(document.activeElement).toBe(separator);
    });

    it('does not start drag when disabled', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          disabled: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      // tabindex should be -1 when disabled
      expect(separator).toHaveAttribute('tabindex', '-1');
    });

    it('does not start drag when readonly', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          readonly: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      // readonly should still be focusable (tabindex="0")
      expect(separator).toHaveAttribute('tabindex', '0');
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(WindowSplitterWithPanes, {
        props: { 'aria-label': 'Resize panels' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when collapsed', async () => {
      const { container } = render(WindowSplitterWithPanes, {
        props: { defaultCollapsed: true, 'aria-label': 'Resize panels' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(WindowSplitterWithPanes, {
        props: { disabled: true, 'aria-label': 'Resize panels' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations for vertical splitter', async () => {
      const { container } = render(WindowSplitterWithPanes, {
        props: { orientation: 'vertical', 'aria-label': 'Resize panels' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-labelledby', async () => {
      const { container } = render(WindowSplitterWithLabel);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Callbacks
  describe('Callbacks', () => {
    it('calls onpositionchange on keyboard interaction', async () => {
      const user = userEvent.setup();
      let capturedPosition: number | undefined;
      let capturedSizeInPx: number | undefined;

      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          'aria-label': 'Resize panels',
          onpositionchange: (pos: number, size: number) => {
            capturedPosition = pos;
            capturedSizeInPx = size;
          },
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(capturedPosition).toBe(55);
      expect(typeof capturedSizeInPx).toBe('number');
    });

    it('calls oncollapsedchange on collapse', async () => {
      const user = userEvent.setup();
      let capturedCollapsed: boolean | undefined;
      let capturedPreviousPosition: number | undefined;

      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
          oncollapsedchange: (collapsed: boolean, prevPos: number) => {
            capturedCollapsed = collapsed;
            capturedPreviousPosition = prevPos;
          },
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}');

      expect(capturedCollapsed).toBe(true);
      expect(capturedPreviousPosition).toBe(50);
    });

    it('calls oncollapsedchange on expand', async () => {
      const user = userEvent.setup();
      let capturedCollapsed: boolean | undefined;

      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultCollapsed: true,
          'aria-label': 'Resize panels',
          oncollapsedchange: (collapsed: boolean) => {
            capturedCollapsed = collapsed;
          },
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}');

      expect(capturedCollapsed).toBe(false);
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('does not exceed max on ArrowRight', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 88,
          max: 90,
          step: 5,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '90');
    });

    it('does not go below min on ArrowLeft', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 12,
          min: 10,
          step: 5,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowLeft}');

      expect(separator).toHaveAttribute('aria-valuenow', '10');
    });

    it('handles min=0 max=100 range', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          min: 0,
          max: 100,
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemin', '0');
      expect(separator).toHaveAttribute('aria-valuemax', '100');
      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('handles custom min/max range', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          min: 20,
          max: 80,
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemin', '20');
      expect(separator).toHaveAttribute('aria-valuemax', '80');
    });

    it('prevents default on handled keyboard events', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '55');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies class to container', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          'aria-label': 'Resize panels',
          class: 'custom-splitter',
        },
      });
      const container = screen.getByRole('separator').closest('.apg-window-splitter');
      expect(container).toHaveClass('custom-splitter');
    });

    it('sets id attribute on separator element', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          'aria-label': 'Resize panels',
          id: 'my-splitter',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('id', 'my-splitter');
    });

    it('passes through data-* attributes', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          'aria-label': 'Resize panels',
          'data-testid': 'custom-splitter',
        },
      });
      expect(screen.getByTestId('custom-splitter')).toBeInTheDocument();
    });
  });
});

リソース