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.vue
<template>
  <div
    ref="containerRef"
    :class="[
      'apg-window-splitter',
      isVertical && 'apg-window-splitter--vertical',
      disabled && 'apg-window-splitter--disabled',
      $attrs.class,
    ]"
    :style="{ '--splitter-position': `${position}%` }"
  >
    <div
      ref="splitterRef"
      role="separator"
      :id="$attrs.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="$attrs['aria-label']"
      :aria-labelledby="$attrs['aria-labelledby']"
      :aria-describedby="$attrs['aria-describedby']"
      :data-testid="$attrs['data-testid']"
      class="apg-window-splitter__separator"
      @keydown="handleKeyDown"
      @pointerdown="handlePointerDown"
      @pointermove="handlePointerMove"
      @pointerup="handlePointerUp"
    />
  </div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue';

defineOptions({
  inheritAttrs: false,
});

export 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;
}

const props = withDefaults(defineProps<WindowSplitterProps>(), {
  secondaryPaneId: undefined,
  defaultPosition: 50,
  defaultCollapsed: false,
  expandedPosition: undefined,
  min: 10,
  max: 90,
  step: 5,
  largeStep: 10,
  orientation: 'horizontal',
  dir: undefined,
  collapsible: true,
  disabled: false,
  readonly: false,
});

const emit = defineEmits<{
  positionChange: [position: number, sizeInPx: number];
  collapsedChange: [collapsed: boolean, previousPosition: number];
}>();

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

// Refs
const splitterRef = ref<HTMLDivElement | null>(null);
const containerRef = ref<HTMLDivElement | null>(null);
const isDragging = ref(false);
const previousPosition = ref<number | null>(
  props.defaultCollapsed ? null : clamp(props.defaultPosition, props.min, props.max)
);

// State
const initialPosition = props.defaultCollapsed
  ? 0
  : clamp(props.defaultPosition, props.min, props.max);
const position = ref(initialPosition);
const collapsed = ref(props.defaultCollapsed);

// Computed
const isVertical = computed(() => props.orientation === 'vertical');
const isHorizontal = computed(() => props.orientation === 'horizontal');

const isRTL = computed(() => {
  if (props.dir === 'rtl') return true;
  if (props.dir === 'ltr') return false;
  if (typeof document !== 'undefined') {
    return document.dir === 'rtl';
  }
  return false;
});

const ariaControls = computed(() => {
  if (props.secondaryPaneId) {
    return `${props.primaryPaneId} ${props.secondaryPaneId}`;
  }
  return props.primaryPaneId;
});

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

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

    emit('positionChange', clampedPosition, sizeInPx);
  }
};

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

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

    emit('collapsedChange', false, position.value);
    collapsed.value = false;
    position.value = clampedRestore;

    const container = containerRef.value;
    const sizeInPx = container
      ? (clampedRestore / 100) *
        (isHorizontal.value ? container.offsetWidth : container.offsetHeight)
      : 0;
    emit('positionChange', clampedRestore, sizeInPx);
  } else {
    // Collapse: save current position, set to 0
    previousPosition.value = position.value;
    emit('collapsedChange', true, position.value);
    collapsed.value = true;
    position.value = 0;
    emit('positionChange', 0, 0);
  }
};

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

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

  let delta = 0;
  let handled = false;

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

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

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

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

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

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

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

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

// Pointer handlers
const handlePointerDown = (event: PointerEvent) => {
  if (props.disabled || props.readonly) return;

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

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

const handlePointerMove = (event: PointerEvent) => {
  if (!isDragging.value) return;

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

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

  let percent: number;
  if (isHorizontal.value) {
    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, props.min, props.max);

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

  updatePosition(percent);
};

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

使い方

Example
<script setup lang="ts">
import { WindowSplitter } from './WindowSplitter.vue';

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

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

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

イベント ペイロード 説明
position-change (position: number, sizeInPx: number) 位置変更時に発火
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構造(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.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
import WindowSplitter from './WindowSplitter.vue';

// Helper to render with panes for aria-controls validation
const renderWithPanes = (
  props: Record<string, unknown> = {},
  attrs: Record<string, unknown> = {}
) => {
  return render({
    components: { WindowSplitter },
    template: `
      <div>
        <div id="primary">Primary Pane</div>
        <div id="secondary">Secondary Pane</div>
        <WindowSplitter v-bind="allProps" />
      </div>
    `,
    data() {
      return {
        allProps: {
          primaryPaneId: 'primary',
          ...props,
          ...attrs,
        },
      };
    },
  });
};

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

    it('has aria-valuenow set to current position (default: 50)', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary' },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { 'aria-label': 'Resize panels' },
      });
      expect(screen.getByRole('separator', { name: 'Resize panels' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render({
        components: { WindowSplitter },
        template: `
          <div>
            <span id="splitter-label">Panel Divider</span>
            <WindowSplitter primaryPaneId="primary" aria-labelledby="splitter-label" />
          </div>
        `,
      });
      expect(screen.getByRole('separator', { name: 'Panel Divider' })).toBeInTheDocument();
    });

    it('supports aria-describedby', () => {
      render({
        components: { WindowSplitter },
        template: `
          <div>
            <WindowSplitter primaryPaneId="primary" aria-label="Resize" aria-describedby="help" />
            <p id="help">Press Enter to collapse</p>
          </div>
        `,
      });
      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' },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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,
        },
        attrs: { '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,
        },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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',
        },
        attrs: { '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',
        },
        attrs: { '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',
        },
        attrs: { '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',
        },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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,
        },
        attrs: { '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,
        },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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',
        },
        attrs: { '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',
        },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('tabindex', '0');
    });

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

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

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

    it('maintains focus after expand', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', defaultCollapsed: true },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

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

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

    it('can be focused via click', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary' },
        attrs: { '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' },
        attrs: { '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', async () => {
      // When disabled, the handler returns early without calling setPointerCapture
      // The key test is that keyboard operations are blocked, not focus behavior
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', disabled: true },
        attrs: { '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', async () => {
      // When readonly, the handler returns early without calling setPointerCapture
      // readonly is focusable but not operable - keyboard tests verify this behavior
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', readonly: true },
        attrs: { '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 } = renderWithPanes({}, { '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 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 for vertical splitter', async () => {
      const { container } = renderWithPanes(
        { 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({
        components: { WindowSplitter },
        template: `
          <div>
            <span id="splitter-label">Panel Divider</span>
            <div id="primary">Primary Pane</div>
            <WindowSplitter primaryPaneId="primary" aria-labelledby="splitter-label" />
          </div>
        `,
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Events
  describe('Events', () => {
    it('emits positionChange on keyboard interaction', async () => {
      const user = userEvent.setup();
      const { emitted } = render(WindowSplitter, {
        props: { primaryPaneId: 'primary', defaultPosition: 50, step: 5 },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

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

      expect(emitted('positionChange')).toBeTruthy();
      const [position] = emitted('positionChange')[0] as [number, number];
      expect(position).toBe(55);
    });

    it('emits collapsedChange on collapse', async () => {
      const user = userEvent.setup();
      const { emitted } = render(WindowSplitter, {
        props: { primaryPaneId: 'primary', defaultPosition: 50 },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

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

      expect(emitted('collapsedChange')).toBeTruthy();
      const [collapsed, previousPosition] = emitted('collapsedChange')[0] as [boolean, number];
      expect(collapsed).toBe(true);
      expect(previousPosition).toBe(50);
    });

    it('emits collapsedChange on expand', async () => {
      const user = userEvent.setup();
      const { emitted } = render(WindowSplitter, {
        props: { primaryPaneId: 'primary', defaultCollapsed: true },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

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

      expect(emitted('collapsedChange')).toBeTruthy();
      const [collapsed] = emitted('collapsedChange')[0] as [boolean, number];
      expect(collapsed).toBe(false);
    });

    it('emits positionChange with sizeInPx parameter', async () => {
      const user = userEvent.setup();
      const { emitted } = render(WindowSplitter, {
        props: { primaryPaneId: 'primary', defaultPosition: 50, step: 5 },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

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

      expect(emitted('positionChange')).toBeTruthy();
      const [position, sizeInPx] = emitted('positionChange')[0] as [number, number];
      expect(position).toBe(55);
      expect(typeof sizeInPx).toBe('number');
    });

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

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

      expect(emitted('positionChange')).toBeFalsy();
    });
  });

  // 🟡 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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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,
        },
        attrs: { '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 },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);

      // The fact that ArrowRight changes the value means preventDefault worked
      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' },
        attrs: { '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' },
        attrs: { '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' },
        attrs: {
          'aria-label': 'Resize panels',
          'data-testid': 'custom-splitter',
        },
      });
      expect(screen.getByTestId('custom-splitter')).toBeInTheDocument();
    });
  });
});

リソース