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-readonly は role="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();
});
});
}); リソース
- WAI-ARIA APG: Window Splitter パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist