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.tsx
import { clsx } from 'clsx';
import type { CSSProperties } from 'react';
import { useCallback, useRef, useState } from 'react';
// CSS custom properties type for splitter position
interface SplitterStyle extends CSSProperties {
'--splitter-position': string;
}
// Label: one of these required (exclusive)
type LabelProps =
| { 'aria-label': string; 'aria-labelledby'?: never }
| { 'aria-label'?: never; 'aria-labelledby': string };
type WindowSplitterBaseProps = {
/** Primary pane ID (required for aria-controls) */
primaryPaneId: string;
/** Secondary pane ID (optional, added to aria-controls) */
secondaryPaneId?: string;
/** Initial position as % (0-100, default: 50) */
defaultPosition?: number;
/** Initial collapsed state (default: false) */
defaultCollapsed?: boolean;
/** Position when expanding from initial collapse */
expandedPosition?: number;
/** Minimum position as % (default: 10) */
min?: number;
/** Maximum position as % (default: 90) */
max?: number;
/** Keyboard step as % (default: 5) */
step?: number;
/** Shift+Arrow step as % (default: 10) */
largeStep?: number;
/** Splitter orientation (default: horizontal = left-right split) */
orientation?: 'horizontal' | 'vertical';
/** Text direction for RTL support */
dir?: 'ltr' | 'rtl';
/** Whether pane can be collapsed (default: true) */
collapsible?: boolean;
/** Disabled state (not focusable, not operable) */
disabled?: boolean;
/** Readonly state (focusable but not operable) */
readonly?: boolean;
/** Callback when position changes */
onPositionChange?: (position: number, sizeInPx: number) => void;
/** Callback when collapsed state changes */
onCollapsedChange?: (collapsed: boolean, previousPosition: number) => void;
/** Reference to help text */
'aria-describedby'?: string;
/** Test id for testing */
'data-testid'?: string;
className?: string;
id?: string;
};
export type WindowSplitterProps = WindowSplitterBaseProps & LabelProps;
// Clamp value to min/max range
const clamp = (value: number, min: number, max: number): number => {
return Math.min(max, Math.max(min, value));
};
export const WindowSplitter: React.FC<WindowSplitterProps> = ({
primaryPaneId,
secondaryPaneId,
defaultPosition = 50,
defaultCollapsed = false,
expandedPosition,
min = 10,
max = 90,
step = 5,
largeStep = 10,
orientation = 'horizontal',
dir,
collapsible = true,
disabled = false,
readonly = false,
onPositionChange,
onCollapsedChange,
'aria-describedby': ariaDescribedby,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
'data-testid': dataTestid,
className,
id,
}) => {
// Calculate initial position: clamp to valid range, or 0 if collapsed
const initialPosition = defaultCollapsed ? 0 : clamp(defaultPosition, min, max);
const [position, setPosition] = useState(initialPosition);
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const splitterRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const previousPositionRef = useRef<number | null>(defaultCollapsed ? null : initialPosition);
const isHorizontal = orientation === 'horizontal';
const isVertical = orientation === 'vertical';
// Determine RTL mode
const isRTL =
dir === 'rtl' ||
(dir === undefined && typeof document !== 'undefined' && document.dir === 'rtl');
// Update position and call callback
const updatePosition = useCallback(
(newPosition: number) => {
const clampedPosition = clamp(newPosition, min, max);
if (clampedPosition !== position) {
setPosition(clampedPosition);
// Calculate size in px (approximation, actual calculation needs container)
const container = containerRef.current;
const sizeInPx = container
? (clampedPosition / 100) *
(isHorizontal ? container.offsetWidth : container.offsetHeight)
: 0;
onPositionChange?.(clampedPosition, sizeInPx);
}
},
[position, min, max, isHorizontal, onPositionChange]
);
// Handle collapse/expand
const handleToggleCollapse = useCallback(() => {
if (!collapsible || disabled || readonly) return;
if (collapsed) {
// Expand: restore to previous or fallback
const restorePosition =
previousPositionRef.current ?? expandedPosition ?? defaultPosition ?? 50;
const clampedRestore = clamp(restorePosition, min, max);
onCollapsedChange?.(false, position);
setCollapsed(false);
setPosition(clampedRestore);
const container = containerRef.current;
const sizeInPx = container
? (clampedRestore / 100) * (isHorizontal ? container.offsetWidth : container.offsetHeight)
: 0;
onPositionChange?.(clampedRestore, sizeInPx);
} else {
// Collapse: save current position, set to 0
previousPositionRef.current = position;
onCollapsedChange?.(true, position);
setCollapsed(true);
setPosition(0);
onPositionChange?.(0, 0);
}
}, [
collapsed,
collapsible,
disabled,
readonly,
position,
expandedPosition,
defaultPosition,
min,
max,
isHorizontal,
onCollapsedChange,
onPositionChange,
]);
// Keyboard handler
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (disabled || readonly) return;
const hasShift = event.shiftKey;
const currentStep = hasShift ? largeStep : step;
let delta = 0;
let handled = false;
switch (event.key) {
// Horizontal splitter: ArrowLeft/Right only
case 'ArrowRight':
if (!isHorizontal) break;
delta = isRTL ? -currentStep : currentStep;
handled = true;
break;
case 'ArrowLeft':
if (!isHorizontal) break;
delta = isRTL ? currentStep : -currentStep;
handled = true;
break;
// Vertical splitter: ArrowUp/Down only
case 'ArrowUp':
if (!isVertical) break;
delta = currentStep;
handled = true;
break;
case 'ArrowDown':
if (!isVertical) break;
delta = -currentStep;
handled = true;
break;
// Collapse/Expand
case 'Enter':
handleToggleCollapse();
handled = true;
break;
// Home/End
case 'Home':
updatePosition(min);
handled = true;
break;
case 'End':
updatePosition(max);
handled = true;
break;
}
if (handled) {
event.preventDefault();
if (delta !== 0) {
updatePosition(position + delta);
}
}
},
[
disabled,
readonly,
isHorizontal,
isVertical,
isRTL,
step,
largeStep,
position,
min,
max,
handleToggleCollapse,
updatePosition,
]
);
// Pointer handlers
const isDraggingRef = useRef(false);
const handlePointerDown = useCallback(
(event: React.PointerEvent) => {
if (disabled || readonly) return;
event.preventDefault();
const splitter = splitterRef.current;
if (!splitter) return;
if (typeof splitter.setPointerCapture === 'function') {
splitter.setPointerCapture(event.pointerId);
}
isDraggingRef.current = true;
splitter.focus();
},
[disabled, readonly]
);
const handlePointerMove = useCallback(
(event: React.PointerEvent) => {
if (!isDraggingRef.current) return;
const container = containerRef.current;
if (!container) return;
// Use demo container for stable measurement if available
const demoContainerElement = container.closest('.apg-window-splitter-demo-container');
const demoContainer =
demoContainerElement instanceof HTMLElement ? demoContainerElement : null;
const measureElement = demoContainer || container.parentElement || container;
const rect = measureElement.getBoundingClientRect();
let percent: number;
if (isHorizontal) {
const x = event.clientX - rect.left;
percent = (x / rect.width) * 100;
} else {
const y = event.clientY - rect.top;
// For vertical, y position corresponds to primary pane height
percent = (y / rect.height) * 100;
}
// Clamp the percent to min/max
const clampedPercent = clamp(percent, min, max);
// Update CSS variable directly for smooth dragging
if (demoContainer) {
demoContainer.style.setProperty('--splitter-position', `${clampedPercent}%`);
}
updatePosition(percent);
},
[isHorizontal, min, max, updatePosition]
);
const handlePointerUp = useCallback((event: React.PointerEvent) => {
const splitter = splitterRef.current;
if (splitter && typeof splitter.releasePointerCapture === 'function') {
try {
splitter.releasePointerCapture(event.pointerId);
} catch {
// Ignore if pointer capture was not set
}
}
isDraggingRef.current = false;
}, []);
// Compute aria-controls
const ariaControls = secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId;
return (
<div
ref={containerRef}
className={clsx(
'apg-window-splitter',
isVertical && 'apg-window-splitter--vertical',
disabled && 'apg-window-splitter--disabled',
className
)}
style={{ '--splitter-position': `${position}%` } satisfies SplitterStyle}
>
<div
ref={splitterRef}
role="separator"
id={id}
tabIndex={disabled ? -1 : 0}
aria-valuenow={position}
aria-valuemin={min}
aria-valuemax={max}
aria-controls={ariaControls}
aria-orientation={isVertical ? 'vertical' : undefined}
aria-disabled={disabled ? true : undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
data-testid={dataTestid}
className="apg-window-splitter__separator"
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
/>
</div>
);
}; 使い方
Example
import { WindowSplitter } from './WindowSplitter';
function App() {
return (
<div className="layout">
<div id="primary-pane" style={{ width: 'var(--splitter-position)' }}>
Primary Content
</div>
<WindowSplitter
primaryPaneId="primary-pane"
secondaryPaneId="secondary-pane"
defaultPosition={50}
min={20}
max={80}
step={5}
aria-label="Resize panels"
onPositionChange={(position, sizeInPx) => {
console.log('Position:', position, 'Size:', sizeInPx);
}}
/>
<div id="secondary-pane">
Secondary Content
</div>
</div>
);
} API
WindowSplitter Props
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
primaryPaneId | string | 必須 | プライマリペインのID(aria-controls用) |
secondaryPaneId | string | - | セカンダリペインのID(任意) |
defaultPosition | number | 50 | 初期位置(パーセンテージ 0-100) |
defaultCollapsed | boolean | false | 折り畳み状態で開始 |
expandedPosition | number | - | 初期折り畳みから展開する際の位置 |
min | number | 10 | 最小位置(%) |
max | number | 90 | 最大位置(%) |
step | number | 5 | キーボードステップサイズ(%) |
largeStep | number | 10 | Shift+矢印のステップサイズ(%) |
orientation | 'horizontal' | 'vertical' | 'horizontal' | スプリッターの向き |
dir | 'ltr' | 'rtl' | - | RTLサポート用のテキスト方向 |
collapsible | boolean | true | Enterで折り畳み/展開を許可 |
disabled | boolean | false | 無効状態 |
readonly | boolean | false | 読み取り専用状態(フォーカス可能だが操作不可) |
onPositionChange | (position: number, sizeInPx: number) => void | - | 位置変更時のコールバック |
onCollapsedChange | (collapsed: boolean, previousPosition: number) => void | - | 折り畳み状態変更時のコールバック |
テスト
テストはARIA構造、キーボードナビゲーション、フォーカス管理、ポインターインタラクション全体でAPG準拠を検証します。 Window Splitterコンポーネントは2層のテスト戦略を使用します。
テスト戦略
ユニットテスト (Container API / Testing Library)
コンポーネントのHTML出力と基本的なインタラクションを検証します。 正しいテンプレートレンダリングとARIA属性を確保します。
- ARIA構造 (role, aria-valuenow, aria-controls)
- キーボードインタラクション (矢印キー, Home/End, Enter)
- 折り畳み/展開機能
- RTLサポート
- 無効/読み取り専用状態
E2Eテスト (Playwright)
ポインターインタラクションを含む実際のブラウザ環境でのコンポーネント動作を検証します。
- ドラッグによるリサイズ
- Tabナビゲーション全体でのフォーカス管理
- フレームワーク間の一貫性
- ビジュアル状態 (CSSカスタムプロパティの更新)
テストカテゴリ
高優先度: ARIA構造
| テスト | 説明 |
|---|---|
role="separator" | スプリッターがseparatorロールを持つ |
aria-valuenow | プライマリペインのサイズ(パーセンテージ 0-100) |
aria-valuemin/max | 最小値と最大値が設定されている |
aria-controls | プライマリ(および任意でセカンダリ)ペインを参照 |
aria-orientation | 垂直スプリッターの場合は "vertical" に設定 |
aria-disabled | 無効時は "true" に設定 |
高優先度: キーボードインタラクション
| テスト | 説明 |
|---|---|
ArrowRight/Left | 水平スプリッターを移動(増加/減少) |
ArrowUp/Down | 垂直スプリッターを移動(増加/減少) |
方向制限 | 間違った方向のキーは無効 |
Shift+Arrow | 大きなステップで移動 |
Home/End | 最小/最大位置に移動 |
Enter (折り畳み) | 0に折り畳む |
Enter (展開) | 以前の位置に復元 |
RTLサポート | RTLモードではArrowLeft/Rightが反転 |
高優先度: フォーカス管理
| テスト | 説明 |
|---|---|
tabindex="0" | スプリッターがフォーカス可能 |
tabindex="-1" | 無効なスプリッターはフォーカス不可 |
readonly フォーカス可能 | 読み取り専用スプリッターはフォーカス可能だが操作不可 |
折り畳み後のフォーカス | フォーカスはスプリッターに残る |
中優先度: ポインターインタラクション
| テスト | 説明 |
|---|---|
ドラッグでリサイズ | ドラッグ中に位置が更新される |
クリックでフォーカス | クリックでスプリッターにフォーカス |
無効時は無応答 | 無効なスプリッターはポインターを無視 |
読み取り専用時は無応答 | 読み取り専用スプリッターはポインターを無視 |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe違反 | WCAG 2.1 AA違反なし |
折り畳み状態 | 折り畳み時に違反なし |
無効状態 | 無効時に違反なし |
テストの実行
ユニットテスト
# 全てのWindow Splitterユニットテストを実行
npx vitest run src/patterns/window-splitter/
# フレームワーク別テストを実行
npm run test:react -- WindowSplitter.test.tsx
npm run test:vue -- WindowSplitter.test.vue.ts
npm run test:svelte -- WindowSplitter.test.svelte.ts
npm run test:astro E2Eテスト
# 全てのWindow Splitter E2Eテストを実行
npm run test:e2e -- window-splitter.spec.ts
# UIモードで実行
npm run test:e2e:ui -- window-splitter.spec.ts WindowSplitter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { WindowSplitter } from './WindowSplitter';
describe('WindowSplitter', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="separator"', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
expect(screen.getByRole('separator')).toBeInTheDocument();
});
it('has aria-valuenow representing primary pane percentage', () => {
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('has aria-valuenow set to defaultPosition (default: 50)', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('has aria-valuenow="0" when collapsed', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}');
expect(splitter).toHaveAttribute('aria-valuenow', '0');
});
it('has aria-valuenow="0" when defaultCollapsed is true', () => {
render(
<WindowSplitter primaryPaneId="primary" defaultCollapsed aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuenow', '0');
});
it('has aria-valuemin set (default: 10)', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuemin', '10');
});
it('has aria-valuemax set (default: 90)', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuemax', '90');
});
it('has custom aria-valuemin when provided', () => {
render(<WindowSplitter primaryPaneId="primary" min={20} aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuemin', '20');
});
it('has custom aria-valuemax when provided', () => {
render(<WindowSplitter primaryPaneId="primary" max={80} aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuemax', '80');
});
it('has aria-controls referencing primary pane', () => {
render(<WindowSplitter primaryPaneId="main-panel" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-controls', 'main-panel');
});
it('has aria-controls with multiple IDs when secondaryPaneId provided', () => {
render(
<WindowSplitter
primaryPaneId="primary"
secondaryPaneId="secondary"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-controls', 'primary secondary');
});
it('does not have aria-orientation for horizontal splitter (default)', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).not.toHaveAttribute('aria-orientation');
});
it('has aria-orientation="vertical" for vertical splitter', () => {
render(
<WindowSplitter primaryPaneId="primary" orientation="vertical" aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-orientation', 'vertical');
});
it('has aria-disabled="true" when disabled', () => {
render(<WindowSplitter primaryPaneId="primary" disabled aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-disabled', 'true');
});
// Note: aria-readonly is not a valid attribute for role="separator"
// Readonly behavior is enforced via JavaScript only
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has accessible name via aria-label', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
expect(screen.getByRole('separator', { name: 'Resize panels' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(
<>
<span id="splitter-label">Adjust panel size</span>
<WindowSplitter primaryPaneId="primary" aria-labelledby="splitter-label" />
</>
);
expect(screen.getByRole('separator', { name: 'Adjust panel size' })).toBeInTheDocument();
});
});
// 🔴 High Priority: Keyboard Interaction - Horizontal Splitter
describe('Keyboard Interaction - Horizontal Splitter', () => {
it('increases value by step on ArrowRight', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '55');
});
it('decreases value by step on ArrowLeft', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowLeft}');
expect(splitter).toHaveAttribute('aria-valuenow', '45');
});
it('ArrowUp does nothing on horizontal splitter', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
orientation="horizontal"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowUp}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('ArrowDown does nothing on horizontal splitter', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
orientation="horizontal"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowDown}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('increases value by largeStep on Shift+ArrowRight', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
largeStep={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Shift>}{ArrowRight}{/Shift}');
expect(splitter).toHaveAttribute('aria-valuenow', '60');
});
it('decreases value by largeStep on Shift+ArrowLeft', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
largeStep={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Shift>}{ArrowLeft}{/Shift}');
expect(splitter).toHaveAttribute('aria-valuenow', '40');
});
});
// 🔴 High Priority: Keyboard Interaction - Vertical Splitter
describe('Keyboard Interaction - Vertical Splitter', () => {
it('increases value by step on ArrowUp', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
orientation="vertical"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowUp}');
expect(splitter).toHaveAttribute('aria-valuenow', '55');
});
it('decreases value by step on ArrowDown', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
orientation="vertical"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowDown}');
expect(splitter).toHaveAttribute('aria-valuenow', '45');
});
it('ArrowLeft does nothing on vertical splitter', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
orientation="vertical"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowLeft}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('ArrowRight does nothing on vertical splitter', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
orientation="vertical"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('increases value by largeStep on Shift+ArrowUp', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
largeStep={10}
orientation="vertical"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Shift>}{ArrowUp}{/Shift}');
expect(splitter).toHaveAttribute('aria-valuenow', '60');
});
});
// 🔴 High Priority: Keyboard Interaction - Collapse/Expand
describe('Keyboard Interaction - Collapse/Expand', () => {
it('collapses on Enter (aria-valuenow becomes 0)', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}');
expect(splitter).toHaveAttribute('aria-valuenow', '0');
});
it('expands to previous value on Enter after collapse', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Collapse → 0
await user.keyboard('{Enter}'); // Expand → 50
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('expands to expandedPosition when initially collapsed', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultCollapsed
expandedPosition={60}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Expand → 60
expect(splitter).toHaveAttribute('aria-valuenow', '60');
});
it('expands to defaultPosition when initially collapsed without expandedPosition', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultCollapsed
defaultPosition={40}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Expand → 40
expect(splitter).toHaveAttribute('aria-valuenow', '40');
});
it('does not collapse when collapsible is false', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
collapsible={false}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('restores correct value after multiple collapse/expand cycles', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}'); // 55
await user.keyboard('{Enter}'); // Collapse → 0
await user.keyboard('{Enter}'); // Expand → 55
expect(splitter).toHaveAttribute('aria-valuenow', '55');
});
});
// 🔴 High Priority: Keyboard Interaction - Home/End
describe('Keyboard Interaction - Home/End', () => {
it('sets min value on Home', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
min={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Home}');
expect(splitter).toHaveAttribute('aria-valuenow', '10');
});
it('sets max value on End', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
max={90}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{End}');
expect(splitter).toHaveAttribute('aria-valuenow', '90');
});
it('does not exceed max on ArrowRight', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={85}
max={90}
step={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '90');
});
it('does not go below min on ArrowLeft', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={15}
min={10}
step={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowLeft}');
expect(splitter).toHaveAttribute('aria-valuenow', '10');
});
});
// 🔴 High Priority: Keyboard Interaction - RTL
describe('Keyboard Interaction - RTL', () => {
it('ArrowLeft increases value in RTL mode', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
dir="rtl"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowLeft}');
expect(splitter).toHaveAttribute('aria-valuenow', '55');
});
it('ArrowRight decreases value in RTL mode', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
dir="rtl"
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '45');
});
});
// 🔴 High Priority: Keyboard Interaction - Disabled/Readonly
describe('Keyboard Interaction - Disabled/Readonly', () => {
it('does not change value when disabled', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
disabled
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
splitter.focus();
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('does not change value when readonly', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
readonly
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
it('does not collapse when disabled', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
disabled
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
splitter.focus();
await user.keyboard('{Enter}');
expect(splitter).toHaveAttribute('aria-valuenow', '50');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" on splitter', () => {
render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('tabindex', '0');
});
it('has tabindex="-1" when disabled', () => {
render(<WindowSplitter primaryPaneId="primary" disabled aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('tabindex', '-1');
});
it('has tabindex="0" when readonly (focusable but not operable)', () => {
render(<WindowSplitter primaryPaneId="primary" readonly aria-label="Resize panels" />);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('tabindex', '0');
});
it('is focusable via Tab', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />
<button>After</button>
</>
);
await user.tab(); // Focus "Before" button
await user.tab(); // Focus splitter
expect(screen.getByRole('separator')).toHaveFocus();
});
it('is not focusable via Tab when disabled', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<WindowSplitter primaryPaneId="primary" disabled aria-label="Resize panels" />
<button>After</button>
</>
);
await user.tab(); // Focus "Before" button
await user.tab(); // Skip splitter, focus "After" button
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
it('focus remains on splitter after collapse', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Collapse
expect(splitter).toHaveFocus();
});
});
// 🟡 Medium Priority: Pointer Interaction
describe('Pointer Interaction', () => {
it('updates position on pointer down', () => {
const handleChange = vi.fn();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });
// Focus should be on splitter
expect(splitter).toHaveFocus();
});
it('does not respond to pointer when disabled', () => {
const handleChange = vi.fn();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
disabled
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });
expect(handleChange).not.toHaveBeenCalled();
});
it('does not respond to pointer when readonly', () => {
const handleChange = vi.fn();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
readonly
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });
expect(handleChange).not.toHaveBeenCalled();
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
// Helper: Render with pane elements for aria-controls validation
const renderWithPanes = (
splitterProps: Partial<Parameters<typeof WindowSplitter>[0]> & {
'aria-label'?: string;
'aria-labelledby'?: string;
}
) => {
return render(
<>
<div id="primary">Primary Pane</div>
<WindowSplitter primaryPaneId="primary" {...splitterProps} />
<div id="secondary">Secondary Pane</div>
</>
);
};
it('has no axe violations', async () => {
const { container } = renderWithPanes({ 'aria-label': 'Resize panels' });
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with aria-labelledby', async () => {
const { container } = render(
<>
<span id="label">Resize panels</span>
<div id="primary">Primary Pane</div>
<WindowSplitter primaryPaneId="primary" aria-labelledby="label" />
</>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = renderWithPanes({
disabled: true,
'aria-label': 'Resize panels',
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when collapsed', async () => {
const { container } = renderWithPanes({
defaultCollapsed: true,
'aria-label': 'Resize panels',
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations for vertical splitter', async () => {
const { container } = renderWithPanes({
orientation: 'vertical',
'aria-label': 'Resize panels',
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Callbacks
describe('Callbacks', () => {
it('calls onPositionChange on keyboard interaction', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
step={5}
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(handleChange).toHaveBeenCalled();
expect(handleChange.mock.calls[0][0]).toBe(55);
});
it('calls onCollapsedChange on collapse', async () => {
const handleCollapse = vi.fn();
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
onCollapsedChange={handleCollapse}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}');
expect(handleCollapse).toHaveBeenCalledWith(true, 50);
});
it('calls onCollapsedChange on expand', async () => {
const handleCollapse = vi.fn();
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultCollapsed
defaultPosition={50}
onCollapsedChange={handleCollapse}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Expand
expect(handleCollapse).toHaveBeenCalledWith(false, 0);
});
it('does not call onPositionChange when disabled', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={50}
disabled
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
splitter.focus();
await user.keyboard('{ArrowRight}');
expect(handleChange).not.toHaveBeenCalled();
});
it('does not call onPositionChange when value does not change', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={90}
max={90}
step={5}
onPositionChange={handleChange}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(handleChange).not.toHaveBeenCalled();
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('clamps defaultPosition to min', () => {
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={5}
min={10}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuenow', '10');
});
it('clamps defaultPosition to max', () => {
render(
<WindowSplitter
primaryPaneId="primary"
defaultPosition={95}
max={90}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-valuenow', '90');
});
it('clamps expandedPosition to min/max', async () => {
const user = userEvent.setup();
render(
<WindowSplitter
primaryPaneId="primary"
defaultCollapsed
expandedPosition={95}
max={90}
aria-label="Resize panels"
/>
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Enter}'); // Expand
expect(splitter).toHaveAttribute('aria-valuenow', '90');
});
it('uses default step of 5', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{ArrowRight}');
expect(splitter).toHaveAttribute('aria-valuenow', '55');
});
it('uses default largeStep of 10', async () => {
const user = userEvent.setup();
render(
<WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
);
const splitter = screen.getByRole('separator');
await user.click(splitter);
await user.keyboard('{Shift>}{ArrowRight}{/Shift}');
expect(splitter).toHaveAttribute('aria-valuenow', '60');
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('applies className to container', () => {
render(
<WindowSplitter
primaryPaneId="primary"
aria-label="Resize panels"
className="custom-splitter"
/>
);
const container = screen.getByRole('separator').closest('.apg-window-splitter');
expect(container).toHaveClass('custom-splitter');
});
it('sets id attribute on splitter element', () => {
render(
<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" id="my-splitter" />
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('id', 'my-splitter');
});
it('supports aria-describedby', () => {
render(
<>
<WindowSplitter
primaryPaneId="primary"
aria-label="Resize panels"
aria-describedby="desc"
/>
<p id="desc">Use arrow keys to resize, Enter to collapse</p>
</>
);
const splitter = screen.getByRole('separator');
expect(splitter).toHaveAttribute('aria-describedby', 'desc');
});
});
}); リソース
- WAI-ARIA APG: Window Splitter パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist