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.tsx
import { clsx } from 'clsx';
import type { CSSProperties } from 'react';
import { useCallback, useRef, useState } from 'react';

// CSS custom properties type for splitter position
interface SplitterStyle extends CSSProperties {
  '--splitter-position': string;
}

// Label: one of these required (exclusive)
type LabelProps =
  | { 'aria-label': string; 'aria-labelledby'?: never }
  | { 'aria-label'?: never; 'aria-labelledby': string };

type WindowSplitterBaseProps = {
  /** 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;

  /** Reference to help text */
  'aria-describedby'?: string;

  /** Test id for testing */
  'data-testid'?: string;

  className?: string;
  id?: string;
};

export type WindowSplitterProps = WindowSplitterBaseProps & LabelProps;

// Clamp value to min/max range
const clamp = (value: number, min: number, max: number): number => {
  return Math.min(max, Math.max(min, value));
};

export const WindowSplitter: React.FC<WindowSplitterProps> = ({
  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,
  'aria-describedby': ariaDescribedby,
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  'data-testid': dataTestid,
  className,
  id,
}) => {
  // Calculate initial position: clamp to valid range, or 0 if collapsed
  const initialPosition = defaultCollapsed ? 0 : clamp(defaultPosition, min, max);

  const [position, setPosition] = useState(initialPosition);
  const [collapsed, setCollapsed] = useState(defaultCollapsed);

  const splitterRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const previousPositionRef = useRef<number | null>(defaultCollapsed ? null : initialPosition);

  const isHorizontal = orientation === 'horizontal';
  const isVertical = orientation === 'vertical';

  // Determine RTL mode
  const isRTL =
    dir === 'rtl' ||
    (dir === undefined && typeof document !== 'undefined' && document.dir === 'rtl');

  // Update position and call callback
  const updatePosition = useCallback(
    (newPosition: number) => {
      const clampedPosition = clamp(newPosition, min, max);
      if (clampedPosition !== position) {
        setPosition(clampedPosition);

        // Calculate size in px (approximation, actual calculation needs container)
        const container = containerRef.current;
        const sizeInPx = container
          ? (clampedPosition / 100) *
            (isHorizontal ? container.offsetWidth : container.offsetHeight)
          : 0;

        onPositionChange?.(clampedPosition, sizeInPx);
      }
    },
    [position, min, max, isHorizontal, onPositionChange]
  );

  // Handle collapse/expand
  const handleToggleCollapse = useCallback(() => {
    if (!collapsible || disabled || readonly) return;

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

      onCollapsedChange?.(false, position);
      setCollapsed(false);
      setPosition(clampedRestore);

      const container = containerRef.current;
      const sizeInPx = container
        ? (clampedRestore / 100) * (isHorizontal ? container.offsetWidth : container.offsetHeight)
        : 0;
      onPositionChange?.(clampedRestore, sizeInPx);
    } else {
      // Collapse: save current position, set to 0
      previousPositionRef.current = position;
      onCollapsedChange?.(true, position);
      setCollapsed(true);
      setPosition(0);
      onPositionChange?.(0, 0);
    }
  }, [
    collapsed,
    collapsible,
    disabled,
    readonly,
    position,
    expandedPosition,
    defaultPosition,
    min,
    max,
    isHorizontal,
    onCollapsedChange,
    onPositionChange,
  ]);

  // Keyboard handler
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      if (disabled || readonly) return;

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

      let delta = 0;
      let handled = false;

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

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

        // Vertical splitter: ArrowUp/Down only
        case 'ArrowUp':
          if (!isVertical) break;
          delta = currentStep;
          handled = true;
          break;

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

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

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

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

      if (handled) {
        event.preventDefault();
        if (delta !== 0) {
          updatePosition(position + delta);
        }
      }
    },
    [
      disabled,
      readonly,
      isHorizontal,
      isVertical,
      isRTL,
      step,
      largeStep,
      position,
      min,
      max,
      handleToggleCollapse,
      updatePosition,
    ]
  );

  // Pointer handlers
  const isDraggingRef = useRef(false);

  const handlePointerDown = useCallback(
    (event: React.PointerEvent) => {
      if (disabled || readonly) return;

      event.preventDefault();
      const splitter = splitterRef.current;
      if (!splitter) return;

      if (typeof splitter.setPointerCapture === 'function') {
        splitter.setPointerCapture(event.pointerId);
      }
      isDraggingRef.current = true;
      splitter.focus();
    },
    [disabled, readonly]
  );

  const handlePointerMove = useCallback(
    (event: React.PointerEvent) => {
      if (!isDraggingRef.current) return;

      const container = containerRef.current;
      if (!container) return;

      // Use demo container for stable measurement if available
      const demoContainerElement = container.closest('.apg-window-splitter-demo-container');
      const demoContainer =
        demoContainerElement instanceof HTMLElement ? demoContainerElement : null;
      const measureElement = demoContainer || container.parentElement || container;
      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);
    },
    [isHorizontal, min, max, updatePosition]
  );

  const handlePointerUp = useCallback((event: React.PointerEvent) => {
    const splitter = splitterRef.current;
    if (splitter && typeof splitter.releasePointerCapture === 'function') {
      try {
        splitter.releasePointerCapture(event.pointerId);
      } catch {
        // Ignore if pointer capture was not set
      }
    }
    isDraggingRef.current = false;
  }, []);

  // Compute aria-controls
  const ariaControls = secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId;

  return (
    <div
      ref={containerRef}
      className={clsx(
        'apg-window-splitter',
        isVertical && 'apg-window-splitter--vertical',
        disabled && 'apg-window-splitter--disabled',
        className
      )}
      style={{ '--splitter-position': `${position}%` } satisfies SplitterStyle}
    >
      <div
        ref={splitterRef}
        role="separator"
        id={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={ariaLabel}
        aria-labelledby={ariaLabelledby}
        aria-describedby={ariaDescribedby}
        data-testid={dataTestid}
        className="apg-window-splitter__separator"
        onKeyDown={handleKeyDown}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      />
    </div>
  );
};

使い方

Example
import { WindowSplitter } from './WindowSplitter';

function App() {
  return (
    <div className="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={(position, sizeInPx) => {
          console.log('Position:', position, 'Size:', sizeInPx);
        }}
      />
      <div id="secondary-pane">
        Secondary Content
      </div>
    </div>
  );
}

API

WindowSplitter Props

プロパティ デフォルト 説明
primaryPaneId string 必須 プライマリペインのID(aria-controls用)
secondaryPaneId string - セカンダリペインのID(任意)
defaultPosition number 50 初期位置(パーセンテージ 0-100)
defaultCollapsed boolean false 折り畳み状態で開始
expandedPosition number - 初期折り畳みから展開する際の位置
min number 10 最小位置(%)
max number 90 最大位置(%)
step number 5 キーボードステップサイズ(%)
largeStep number 10 Shift+矢印のステップサイズ(%)
orientation 'horizontal' | 'vertical' 'horizontal' スプリッターの向き
dir 'ltr' | 'rtl' - RTLサポート用のテキスト方向
collapsible boolean true Enterで折り畳み/展開を許可
disabled boolean false 無効状態
readonly boolean false 読み取り専用状態(フォーカス可能だが操作不可)
onPositionChange (position: number, sizeInPx: number) => void - 位置変更時のコールバック
onCollapsedChange (collapsed: boolean, previousPosition: number) => void - 折り畳み状態変更時のコールバック

テスト

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

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

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

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

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

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

テスト 説明
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.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { WindowSplitter } from './WindowSplitter';

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

    it('has aria-valuenow representing primary pane percentage', () => {
      render(
        <WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
      );
      const splitter = screen.getByRole('separator');
      expect(splitter).toHaveAttribute('aria-valuenow', '50');
    });

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

    it('has aria-valuenow="0" when collapsed', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
      );
      const splitter = screen.getByRole('separator');

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

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

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

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

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

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

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

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

    it('has aria-controls with multiple IDs when secondaryPaneId provided', () => {
      render(
        <WindowSplitter
          primaryPaneId="primary"
          secondaryPaneId="secondary"
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');
      expect(splitter).toHaveAttribute('aria-controls', 'primary secondary');
    });

    it('does not have aria-orientation for horizontal splitter (default)', () => {
      render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
      const splitter = screen.getByRole('separator');
      expect(splitter).not.toHaveAttribute('aria-orientation');
    });

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

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

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

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

    it('has accessible name via aria-labelledby', () => {
      render(
        <>
          <span id="splitter-label">Adjust panel size</span>
          <WindowSplitter primaryPaneId="primary" aria-labelledby="splitter-label" />
        </>
      );
      expect(screen.getByRole('separator', { name: 'Adjust panel size' })).toBeInTheDocument();
    });
  });

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

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

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

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

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

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

    it('ArrowUp does nothing on horizontal splitter', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          orientation="horizontal"
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

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

    it('ArrowDown does nothing on horizontal splitter', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          orientation="horizontal"
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      await user.click(splitter);
      await user.keyboard('{Shift>}{ArrowUp}{/Shift}');

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

  // 🔴 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 primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
      );
      const splitter = screen.getByRole('separator');

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

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

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

      await user.click(splitter);
      await user.keyboard('{Enter}'); // Collapse → 0
      await user.keyboard('{Enter}'); // Expand → 50

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

    it('expands to expandedPosition when initially collapsed', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultCollapsed
          expandedPosition={60}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      await user.click(splitter);
      await user.keyboard('{Enter}'); // Expand → 60

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

    it('expands to defaultPosition when initially collapsed without expandedPosition', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultCollapsed
          defaultPosition={40}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      await user.click(splitter);
      await user.keyboard('{Enter}'); // Expand → 40

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

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

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

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

    it('restores correct value after multiple collapse/expand cycles', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          step={5}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      await user.click(splitter);
      await user.keyboard('{ArrowRight}'); // 55
      await user.keyboard('{Enter}'); // Collapse → 0
      await user.keyboard('{Enter}'); // Expand → 55

      expect(splitter).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
          primaryPaneId="primary"
          defaultPosition={50}
          min={10}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

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

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

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

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

    it('does not exceed max on ArrowRight', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={85}
          max={90}
          step={10}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

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

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

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

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

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

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

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

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

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

      expect(splitter).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
          primaryPaneId="primary"
          defaultPosition={50}
          disabled
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

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

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

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

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

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

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

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

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

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

    it('has tabindex="0" when readonly (focusable but not operable)', () => {
      render(<WindowSplitter primaryPaneId="primary" readonly aria-label="Resize panels" />);
      const splitter = screen.getByRole('separator');
      expect(splitter).toHaveAttribute('tabindex', '0');
    });

    it('is focusable via Tab', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />
          <button>After</button>
        </>
      );

      await user.tab(); // Focus "Before" button
      await user.tab(); // Focus splitter

      expect(screen.getByRole('separator')).toHaveFocus();
    });

    it('is not focusable via Tab when disabled', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <WindowSplitter primaryPaneId="primary" disabled aria-label="Resize panels" />
          <button>After</button>
        </>
      );

      await user.tab(); // Focus "Before" button
      await user.tab(); // Skip splitter, focus "After" button

      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
    });

    it('focus remains on splitter after collapse', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
      );
      const splitter = screen.getByRole('separator');

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

      expect(splitter).toHaveFocus();
    });
  });

  // 🟡 Medium Priority: Pointer Interaction
  describe('Pointer Interaction', () => {
    it('updates position on pointer down', () => {
      const handleChange = vi.fn();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });

      // Focus should be on splitter
      expect(splitter).toHaveFocus();
    });

    it('does not respond to pointer when disabled', () => {
      const handleChange = vi.fn();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          disabled
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });

      expect(handleChange).not.toHaveBeenCalled();
    });

    it('does not respond to pointer when readonly', () => {
      const handleChange = vi.fn();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          readonly
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });

      expect(handleChange).not.toHaveBeenCalled();
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    // Helper: Render with pane elements for aria-controls validation
    const renderWithPanes = (
      splitterProps: Partial<Parameters<typeof WindowSplitter>[0]> & {
        'aria-label'?: string;
        'aria-labelledby'?: string;
      }
    ) => {
      return render(
        <>
          <div id="primary">Primary Pane</div>
          <WindowSplitter primaryPaneId="primary" {...splitterProps} />
          <div id="secondary">Secondary Pane</div>
        </>
      );
    };

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

    it('has no axe violations with aria-labelledby', async () => {
      const { container } = render(
        <>
          <span id="label">Resize panels</span>
          <div id="primary">Primary Pane</div>
          <WindowSplitter primaryPaneId="primary" aria-labelledby="label" />
        </>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

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

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

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

  // 🟡 Medium Priority: Callbacks
  describe('Callbacks', () => {
    it('calls onPositionChange on keyboard interaction', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          step={5}
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

      expect(handleChange).toHaveBeenCalled();
      expect(handleChange.mock.calls[0][0]).toBe(55);
    });

    it('calls onCollapsedChange on collapse', async () => {
      const handleCollapse = vi.fn();
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          onCollapsedChange={handleCollapse}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

      expect(handleCollapse).toHaveBeenCalledWith(true, 50);
    });

    it('calls onCollapsedChange on expand', async () => {
      const handleCollapse = vi.fn();
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultCollapsed
          defaultPosition={50}
          onCollapsedChange={handleCollapse}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

      expect(handleCollapse).toHaveBeenCalledWith(false, 0);
    });

    it('does not call onPositionChange when disabled', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          disabled
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

      expect(handleChange).not.toHaveBeenCalled();
    });

    it('does not call onPositionChange when value does not change', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={90}
          max={90}
          step={5}
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

      expect(handleChange).not.toHaveBeenCalled();
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('clamps defaultPosition to min', () => {
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={5}
          min={10}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');
      expect(splitter).toHaveAttribute('aria-valuenow', '10');
    });

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

    it('clamps expandedPosition to min/max', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultCollapsed
          expandedPosition={95}
          max={90}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

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

    it('uses default step of 5', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
      );
      const splitter = screen.getByRole('separator');

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

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

    it('uses default largeStep of 10', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
      );
      const splitter = screen.getByRole('separator');

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

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

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

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

    it('supports aria-describedby', () => {
      render(
        <>
          <WindowSplitter
            primaryPaneId="primary"
            aria-label="Resize panels"
            aria-describedby="desc"
          />
          <p id="desc">Use arrow keys to resize, Enter to collapse</p>
        </>
      );
      const splitter = screen.getByRole('separator');
      expect(splitter).toHaveAttribute('aria-describedby', 'desc');
    });
  });
});

リソース