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.svelte
<script lang="ts">
interface WindowSplitterProps {
/** Primary pane ID (required for aria-controls) */
primaryPaneId: string;
/** Secondary pane ID (optional, added to aria-controls) */
secondaryPaneId?: string;
/** Initial position as % (0-100, default: 50) */
defaultPosition?: number;
/** Initial collapsed state (default: false) */
defaultCollapsed?: boolean;
/** Position when expanding from initial collapse */
expandedPosition?: number;
/** Minimum position as % (default: 10) */
min?: number;
/** Maximum position as % (default: 90) */
max?: number;
/** Keyboard step as % (default: 5) */
step?: number;
/** Shift+Arrow step as % (default: 10) */
largeStep?: number;
/** Splitter orientation (default: horizontal = left-right split) */
orientation?: 'horizontal' | 'vertical';
/** Text direction for RTL support */
dir?: 'ltr' | 'rtl';
/** Whether pane can be collapsed (default: true) */
collapsible?: boolean;
/** Disabled state (not focusable, not operable) */
disabled?: boolean;
/** Readonly state (focusable but not operable) */
readonly?: boolean;
/** Callback when position changes */
onpositionchange?: (position: number, sizeInPx: number) => void;
/** Callback when collapsed state changes */
oncollapsedchange?: (collapsed: boolean, previousPosition: number) => void;
[key: string]: unknown;
}
let {
primaryPaneId,
secondaryPaneId,
defaultPosition = 50,
defaultCollapsed = false,
expandedPosition,
min = 10,
max = 90,
step = 5,
largeStep = 10,
orientation = 'horizontal',
dir,
collapsible = true,
disabled = false,
readonly = false,
onpositionchange,
oncollapsedchange,
...restProps
}: WindowSplitterProps = $props();
// Utility function
function clamp(value: number, minVal: number, maxVal: number): number {
return Math.min(maxVal, Math.max(minVal, value));
}
// Refs
let splitterEl: HTMLDivElement | null = null;
let containerEl: HTMLDivElement | null = null;
let isDragging = $state(false);
// State - capture initial prop values (intentionally not reactive to prop changes)
// Using IIFE to avoid Svelte's state_referenced_locally warning
const { initPosition, initCollapsed, initPreviousPosition } = (() => {
const collapsed = defaultCollapsed;
const pos = collapsed ? 0 : clamp(defaultPosition, min, max);
const prevPos = collapsed ? null : clamp(defaultPosition, min, max);
return { initPosition: pos, initCollapsed: collapsed, initPreviousPosition: prevPos };
})();
let position = $state(initPosition);
let collapsed = $state(initCollapsed);
let previousPosition: number | null = initPreviousPosition;
// Computed
const isVertical = $derived(orientation === 'vertical');
const isHorizontal = $derived(orientation === 'horizontal');
const isRTL = $derived(
dir === 'rtl' || (dir !== 'ltr' && typeof document !== 'undefined' && document.dir === 'rtl')
);
const ariaControls = $derived(
secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId
);
// Update position and emit
function updatePosition(newPosition: number) {
const clampedPosition = clamp(newPosition, min, max);
if (clampedPosition !== position) {
position = clampedPosition;
const sizeInPx = containerEl
? (clampedPosition / 100) *
(isHorizontal ? containerEl.offsetWidth : containerEl.offsetHeight)
: 0;
onpositionchange?.(clampedPosition, sizeInPx);
}
}
// Handle collapse/expand
function handleToggleCollapse() {
if (!collapsible || disabled || readonly) return;
if (collapsed) {
// Expand: restore to previous or fallback
const restorePosition = previousPosition ?? expandedPosition ?? defaultPosition ?? 50;
const clampedRestore = clamp(restorePosition, min, max);
oncollapsedchange?.(false, position);
collapsed = false;
position = clampedRestore;
const sizeInPx = containerEl
? (clampedRestore / 100) *
(isHorizontal ? containerEl.offsetWidth : containerEl.offsetHeight)
: 0;
onpositionchange?.(clampedRestore, sizeInPx);
} else {
// Collapse: save current position, set to 0
previousPosition = position;
oncollapsedchange?.(true, position);
collapsed = true;
position = 0;
onpositionchange?.(0, 0);
}
}
// Keyboard handler
function handleKeyDown(event: KeyboardEvent) {
if (disabled || readonly) return;
const hasShift = event.shiftKey;
const currentStep = hasShift ? largeStep : step;
let delta = 0;
let handled = false;
switch (event.key) {
case 'ArrowRight':
if (!isHorizontal) break;
delta = isRTL ? -currentStep : currentStep;
handled = true;
break;
case 'ArrowLeft':
if (!isHorizontal) break;
delta = isRTL ? currentStep : -currentStep;
handled = true;
break;
case 'ArrowUp':
if (!isVertical) break;
delta = currentStep;
handled = true;
break;
case 'ArrowDown':
if (!isVertical) break;
delta = -currentStep;
handled = true;
break;
case 'Enter':
handleToggleCollapse();
handled = true;
break;
case 'Home':
updatePosition(min);
handled = true;
break;
case 'End':
updatePosition(max);
handled = true;
break;
}
if (handled) {
event.preventDefault();
if (delta !== 0) {
updatePosition(position + delta);
}
}
}
// Pointer handlers
function handlePointerDown(event: PointerEvent) {
if (disabled || readonly) return;
event.preventDefault();
if (!splitterEl) return;
if (typeof splitterEl.setPointerCapture === 'function') {
splitterEl.setPointerCapture(event.pointerId);
}
isDragging = true;
splitterEl.focus();
}
function handlePointerMove(event: PointerEvent) {
if (!isDragging) return;
if (!containerEl) return;
// Use demo container for stable measurement if available
const demoContainer = containerEl.closest(
'.apg-window-splitter-demo-container'
) as HTMLElement | null;
const measureElement = demoContainer || containerEl.parentElement || containerEl;
const rect = measureElement.getBoundingClientRect();
let percent: number;
if (isHorizontal) {
const x = event.clientX - rect.left;
percent = (x / rect.width) * 100;
} else {
const y = event.clientY - rect.top;
// For vertical, y position corresponds to primary pane height
percent = (y / rect.height) * 100;
}
// Clamp the percent to min/max
const clampedPercent = clamp(percent, min, max);
// Update CSS variable directly for smooth dragging
if (demoContainer) {
demoContainer.style.setProperty('--splitter-position', `${clampedPercent}%`);
}
updatePosition(percent);
}
function handlePointerUp(event: PointerEvent) {
if (splitterEl && typeof splitterEl.releasePointerCapture === 'function') {
try {
splitterEl.releasePointerCapture(event.pointerId);
} catch {
// Ignore
}
}
isDragging = false;
}
</script>
<div
bind:this={containerEl}
class="apg-window-splitter {isVertical ? 'apg-window-splitter--vertical' : ''} {disabled
? 'apg-window-splitter--disabled'
: ''} {restProps.class || ''}"
style="--splitter-position: {position}%"
>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- role="separator" with aria-valuenow is a focusable widget per WAI-ARIA spec -->
<div
bind:this={splitterEl}
role="separator"
id={restProps.id}
tabindex={disabled ? -1 : 0}
aria-valuenow={position}
aria-valuemin={min}
aria-valuemax={max}
aria-controls={ariaControls}
aria-orientation={isVertical ? 'vertical' : undefined}
aria-disabled={disabled ? true : undefined}
aria-label={restProps['aria-label']}
aria-labelledby={restProps['aria-labelledby']}
aria-describedby={restProps['aria-describedby']}
data-testid={restProps['data-testid']}
class="apg-window-splitter__separator"
onkeydown={handleKeyDown}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
></div>
</div> 使い方
Example
<script lang="ts">
import WindowSplitter from './WindowSplitter.svelte';
function handlePositionChange(event) {
console.log('Position:', event.detail.position, 'Size:', event.detail.sizeInPx);
}
</script>
<div class="layout">
<div id="primary-pane" style="width: var(--splitter-position)">
Primary Content
</div>
<WindowSplitter
primaryPaneId="primary-pane"
secondaryPaneId="secondary-pane"
defaultPosition={50}
min={20}
max={80}
step={5}
aria-label="Resize panels"
onpositionchange={handlePositionChange}
/>
<div id="secondary-pane">
Secondary Content
</div>
</div> API
WindowSplitter Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
primaryPaneId | string | 必須 | プライマリペインのID(aria-controls用) |
secondaryPaneId | string | - | セカンダリペインのID(任意) |
defaultPosition | number | 50 | 初期位置(パーセンテージ 0-100) |
orientation | 'horizontal' | 'vertical' | 'horizontal' | スプリッターの向き |
min / max | number | 10 / 90 | 最小/最大位置(%) |
disabled / readonly | boolean | false | 無効/読み取り専用状態 |
Events
| イベント | 詳細 | 説明 |
|---|---|---|
positionchange | { position: number, sizeInPx: number } | 位置変更時に発火 |
collapsedchange | { collapsed: boolean, previousPosition: number } | 折り畳み状態変更時に発火 |
テスト
テストはARIA構造、キーボードナビゲーション、フォーカス管理、ポインターインタラクション全体でAPG準拠を検証します。
Window Splitterコンポーネントは2層のテスト戦略を使用します。
テスト戦略
ユニットテスト (Container API / Testing Library)
コンポーネントのHTML出力と基本的なインタラクションを検証します。
正しいテンプレートレンダリングとARIA属性を確保します。
- ARIA構造 (role, aria-valuenow, aria-controls)
- キーボードインタラクション (矢印キー, Home/End, Enter)
- 折り畳み/展開機能
- RTLサポート
- 無効/読み取り専用状態
E2Eテスト (Playwright)
ポインターインタラクションを含む実際のブラウザ環境でのコンポーネント動作を検証します。
- ドラッグによるリサイズ
- Tabナビゲーション全体でのフォーカス管理
- フレームワーク間の一貫性
- ビジュアル状態 (CSSカスタムプロパティの更新)
テストカテゴリ
高優先度: ARIA構造(Unit + E2E)
テスト 説明 role="separator" スプリッターがseparatorロールを持つ aria-valuenow プライマリペインのサイズ(パーセンテージ 0-100) aria-valuemin/max 最小値と最大値が設定されている aria-controls プライマリ(および任意でセカンダリ)ペインを参照 aria-orientation 垂直スプリッターの場合は "vertical" に設定 aria-disabled 無効時は "true" に設定
高優先度: キーボードインタラクション(Unit + E2E)
テスト 説明 ArrowRight/Left 水平スプリッターを移動(増加/減少) ArrowUp/Down 垂直スプリッターを移動(増加/減少) 方向制限 間違った方向のキーは無効 Shift+Arrow 大きなステップで移動 Home/End 最小/最大位置に移動 Enter (折り畳み) 0に折り畳む Enter (展開) 以前の位置に復元 RTLサポート RTLモードではArrowLeft/Rightが反転
高優先度: フォーカス管理(Unit + E2E)
テスト 説明 tabindex="0" スプリッターがフォーカス可能 tabindex="-1" 無効なスプリッターはフォーカス不可 readonly フォーカス可能 読み取り専用スプリッターはフォーカス可能だが操作不可 折り畳み後のフォーカス フォーカスはスプリッターに残る
中優先度: ポインターインタラクション(E2E)
テスト 説明 ドラッグでリサイズ ドラッグ中に位置が更新される クリックでフォーカス クリックでスプリッターにフォーカス 無効時は無応答 無効なスプリッターはポインターを無視 読み取り専用時は無応答 読み取り専用スプリッターはポインターを無視
中優先度: アクセシビリティ(E2E)
テスト 説明 axe違反 WCAG 2.1 AA違反なし 折り畳み状態 折り畳み時に違反なし 無効状態 無効時に違反なし
テストの実行
ユニットテスト
# 全てのWindow Splitterユニットテストを実行
npx vitest run src/patterns/window-splitter/
# フレームワーク別テストを実行
npm run test:react -- WindowSplitter.test.tsx
npm run test:vue -- WindowSplitter.test.vue.ts
npm run test:svelte -- WindowSplitter.test.svelte.ts
npm run test:astro
E2Eテスト
# 全てのWindow Splitter E2Eテストを実行
npm run test:e2e -- window-splitter.spec.ts
# UIモードで実行
npm run test:e2e:ui -- window-splitter.spec.ts
WindowSplitter.test.svelte.ts import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
import WindowSplitter from './WindowSplitter.svelte';
import WindowSplitterWithLabel from './WindowSplitterWithLabel.test.svelte';
import WindowSplitterWithDescribedby from './WindowSplitterWithDescribedby.test.svelte';
import WindowSplitterWithPanes from './WindowSplitterWithPanes.test.svelte';
describe('WindowSplitter (Svelte)', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="separator"', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
expect(screen.getByRole('separator')).toBeInTheDocument();
});
it('has aria-valuenow set to current position (default: 50)', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('has aria-valuenow set to custom defaultPosition', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 30,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuenow', '30');
});
it('has aria-valuemin set (default: 10)', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemin', '10');
});
it('has aria-valuemax set (default: 90)', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemax', '90');
});
it('has custom aria-valuemin when provided', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', min: 5, 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemin', '5');
});
it('has custom aria-valuemax when provided', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', max: 95, 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemax', '95');
});
it('has aria-controls referencing primary pane', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'main-panel', 'aria-label': 'Resize panels' },
});
expect(screen.getByRole('separator')).toHaveAttribute('aria-controls', 'main-panel');
});
it('has aria-controls referencing both panes when secondaryPaneId provided', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
secondaryPaneId: 'secondary',
'aria-label': 'Resize panels',
},
});
expect(screen.getByRole('separator')).toHaveAttribute('aria-controls', 'primary secondary');
});
it('has aria-valuenow="0" when collapsed', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultCollapsed: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuenow', '0');
});
it('has aria-disabled="true" when disabled', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
disabled: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-disabled', 'true');
});
it('does not have aria-disabled when not disabled', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).not.toHaveAttribute('aria-disabled');
});
// Note: aria-readonly is not a valid attribute for role="separator"
// Readonly behavior is enforced via JavaScript only
it('clamps defaultPosition to min', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 5,
min: 10,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuenow', '10');
});
it('clamps defaultPosition to max', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 95,
max: 90,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuenow', '90');
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has accessible name via aria-label', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
expect(screen.getByRole('separator', { name: 'Resize panels' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(WindowSplitterWithLabel);
expect(screen.getByRole('separator', { name: 'Panel Divider' })).toBeInTheDocument();
});
it('supports aria-describedby', () => {
render(WindowSplitterWithDescribedby);
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-describedby', 'help');
});
});
// 🔴 High Priority: Orientation
describe('Orientation', () => {
it('does not have aria-orientation for horizontal splitter (default)', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).not.toHaveAttribute('aria-orientation');
});
it('has aria-orientation="vertical" for vertical splitter', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
orientation: 'vertical',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-orientation', 'vertical');
});
});
// 🔴 High Priority: Keyboard Interaction - Horizontal
describe('Keyboard Interaction - Horizontal', () => {
it('increases value by step on ArrowRight', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '55');
});
it('decreases value by step on ArrowLeft', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowLeft}');
expect(separator).toHaveAttribute('aria-valuenow', '45');
});
it('increases value by largeStep on Shift+ArrowRight', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
largeStep: 10,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Shift>}{ArrowRight}{/Shift}');
expect(separator).toHaveAttribute('aria-valuenow', '60');
});
it('decreases value by largeStep on Shift+ArrowLeft', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
largeStep: 10,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Shift>}{ArrowLeft}{/Shift}');
expect(separator).toHaveAttribute('aria-valuenow', '40');
});
it('ignores ArrowUp on horizontal splitter', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowUp}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('ignores ArrowDown on horizontal splitter', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowDown}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
});
// 🔴 High Priority: Keyboard Interaction - Vertical
describe('Keyboard Interaction - Vertical', () => {
it('increases value by step on ArrowUp', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
orientation: 'vertical',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowUp}');
expect(separator).toHaveAttribute('aria-valuenow', '55');
});
it('decreases value by step on ArrowDown', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
orientation: 'vertical',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowDown}');
expect(separator).toHaveAttribute('aria-valuenow', '45');
});
it('ignores ArrowLeft on vertical splitter', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
orientation: 'vertical',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowLeft}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('ignores ArrowRight on vertical splitter', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
orientation: 'vertical',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
});
// 🔴 High Priority: Keyboard Interaction - Collapse/Expand
describe('Keyboard Interaction - Collapse/Expand', () => {
it('collapses on Enter (aria-valuenow becomes 0)', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}');
expect(separator).toHaveAttribute('aria-valuenow', '0');
});
it('restores previous value on Enter after collapse', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}'); // Collapse
await user.keyboard('{Enter}'); // Expand
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('expands to expandedPosition when initially collapsed', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultCollapsed: true,
expandedPosition: 30,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}'); // Expand
expect(separator).toHaveAttribute('aria-valuenow', '30');
});
it('does not collapse when collapsible is false', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
collapsible: false,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('remembers position across multiple collapse/expand cycles', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}'); // 55
await user.keyboard('{Enter}'); // Collapse → 0
expect(separator).toHaveAttribute('aria-valuenow', '0');
await user.keyboard('{Enter}'); // Expand → 55
expect(separator).toHaveAttribute('aria-valuenow', '55');
});
});
// 🔴 High Priority: Keyboard Interaction - Home/End
describe('Keyboard Interaction - Home/End', () => {
it('sets min value on Home', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
min: 10,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Home}');
expect(separator).toHaveAttribute('aria-valuenow', '10');
});
it('sets max value on End', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
max: 90,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{End}');
expect(separator).toHaveAttribute('aria-valuenow', '90');
});
});
// 🔴 High Priority: Keyboard Interaction - RTL
describe('Keyboard Interaction - RTL', () => {
it('ArrowLeft increases value in RTL mode (horizontal)', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
dir: 'rtl',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowLeft}');
expect(separator).toHaveAttribute('aria-valuenow', '55');
});
it('ArrowRight decreases value in RTL mode (horizontal)', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
dir: 'rtl',
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '45');
});
});
// 🔴 High Priority: Keyboard Interaction - Disabled/Readonly
describe('Keyboard Interaction - Disabled/Readonly', () => {
it('does not change value when disabled', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
disabled: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
separator.focus();
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('does not change value when readonly', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
readonly: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('does not collapse when disabled', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
disabled: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
separator.focus();
await user.keyboard('{Enter}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('does not collapse when readonly', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
readonly: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" on separator', () => {
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('tabindex', '0');
});
it('has tabindex="-1" when disabled', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
disabled: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('tabindex', '-1');
});
it('is focusable when readonly', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
readonly: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('tabindex', '0');
});
it('can be focused via click', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
await user.click(separator);
expect(document.activeElement).toBe(separator);
});
});
// 🟡 Medium Priority: Pointer Interaction
describe('Pointer Interaction', () => {
it('focuses separator on pointer down', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
});
const separator = screen.getByRole('separator');
await user.pointer({ target: separator, keys: '[MouseLeft>]' });
expect(document.activeElement).toBe(separator);
});
it('does not start drag when disabled', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
disabled: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
// tabindex should be -1 when disabled
expect(separator).toHaveAttribute('tabindex', '-1');
});
it('does not start drag when readonly', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
readonly: true,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
// readonly should still be focusable (tabindex="0")
expect(separator).toHaveAttribute('tabindex', '0');
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(WindowSplitterWithPanes, {
props: { 'aria-label': 'Resize panels' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when collapsed', async () => {
const { container } = render(WindowSplitterWithPanes, {
props: { defaultCollapsed: true, 'aria-label': 'Resize panels' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = render(WindowSplitterWithPanes, {
props: { disabled: true, 'aria-label': 'Resize panels' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations for vertical splitter', async () => {
const { container } = render(WindowSplitterWithPanes, {
props: { orientation: 'vertical', 'aria-label': 'Resize panels' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with aria-labelledby', async () => {
const { container } = render(WindowSplitterWithLabel);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Callbacks
describe('Callbacks', () => {
it('calls onpositionchange on keyboard interaction', async () => {
const user = userEvent.setup();
let capturedPosition: number | undefined;
let capturedSizeInPx: number | undefined;
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
step: 5,
'aria-label': 'Resize panels',
onpositionchange: (pos: number, size: number) => {
capturedPosition = pos;
capturedSizeInPx = size;
},
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(capturedPosition).toBe(55);
expect(typeof capturedSizeInPx).toBe('number');
});
it('calls oncollapsedchange on collapse', async () => {
const user = userEvent.setup();
let capturedCollapsed: boolean | undefined;
let capturedPreviousPosition: number | undefined;
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
oncollapsedchange: (collapsed: boolean, prevPos: number) => {
capturedCollapsed = collapsed;
capturedPreviousPosition = prevPos;
},
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}');
expect(capturedCollapsed).toBe(true);
expect(capturedPreviousPosition).toBe(50);
});
it('calls oncollapsedchange on expand', async () => {
const user = userEvent.setup();
let capturedCollapsed: boolean | undefined;
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultCollapsed: true,
'aria-label': 'Resize panels',
oncollapsedchange: (collapsed: boolean) => {
capturedCollapsed = collapsed;
},
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{Enter}');
expect(capturedCollapsed).toBe(false);
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('does not exceed max on ArrowRight', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 88,
max: 90,
step: 5,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '90');
});
it('does not go below min on ArrowLeft', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 12,
min: 10,
step: 5,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowLeft}');
expect(separator).toHaveAttribute('aria-valuenow', '10');
});
it('handles min=0 max=100 range', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
min: 0,
max: 100,
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemin', '0');
expect(separator).toHaveAttribute('aria-valuemax', '100');
expect(separator).toHaveAttribute('aria-valuenow', '50');
});
it('handles custom min/max range', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
min: 20,
max: 80,
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('aria-valuemin', '20');
expect(separator).toHaveAttribute('aria-valuemax', '80');
});
it('prevents default on handled keyboard events', async () => {
const user = userEvent.setup();
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
defaultPosition: 50,
'aria-label': 'Resize panels',
},
});
const separator = screen.getByRole('separator');
await user.click(separator);
await user.keyboard('{ArrowRight}');
expect(separator).toHaveAttribute('aria-valuenow', '55');
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('applies class to container', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
'aria-label': 'Resize panels',
class: 'custom-splitter',
},
});
const container = screen.getByRole('separator').closest('.apg-window-splitter');
expect(container).toHaveClass('custom-splitter');
});
it('sets id attribute on separator element', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
'aria-label': 'Resize panels',
id: 'my-splitter',
},
});
const separator = screen.getByRole('separator');
expect(separator).toHaveAttribute('id', 'my-splitter');
});
it('passes through data-* attributes', () => {
render(WindowSplitter, {
props: {
primaryPaneId: 'primary',
'aria-label': 'Resize panels',
'data-testid': 'custom-splitter',
},
});
expect(screen.getByTestId('custom-splitter')).toBeInTheDocument();
});
});
});
リソース
-
WAI-ARIA APG: Window Splitter パターン
(opens in new tab)
-
AI Implementation Guide (llm.md)
(opens in new tab)
- ARIA specs, keyboard support, test checklist