Window Splitter
2つのペイン間で移動可能なセパレーター。ユーザーが各ペインの相対的なサイズを変更できます。IDE、ファイルブラウザ、リサイズ可能なレイアウトで使用されます。
デモ
矢印キーでスプリッターを移動します。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 プロパティ
aria-valuenow
プライマリペインのサイズ(パーセンテージ)
- 値
- 0-100
- 必須
- はい
aria-valuemin
最小値(デフォルト: 10)
- 値
- number
- 必須
- はい
aria-valuemax
最大値(デフォルト: 90)
- 値
- number
- 必須
- はい
aria-controls
プライマリペインのID(+ セカンダリペインのIDは任意)
- 値
- ID reference(s)
- 必須
- はい
aria-label
アクセシブルな名前
- 値
- string
- 必須
- 条件付き(aria-labelledbyがない場合は必須)
aria-labelledby
表示されるラベル要素への参照
- 値
- ID reference
- 必須
- 条件付き(aria-labelがない場合は必須)
aria-orientation
デフォルト: horizontal(左右分割)
- 値
horizontal|vertical- 必須
- いいえ
aria-disabled
無効状態
- 値
- true | false
- 必須
- いいえ
WAI-ARIA ステート
aria-valuenow
- 対象要素
- separator要素
- 値
- 0-100(0 = 折り畳み、50 = 半分、100 = 完全展開)
- 必須
- はい
- 変更トリガー
矢印キー、Home/End、Enter(折り畳み/展開)、ポインタードラッグ
キーボードサポート
| キー | アクション |
|---|---|
| Arrow Right / Arrow Left | 水平スプリッターを移動(増加/減少) |
| Arrow Up / Arrow Down | 垂直スプリッターを移動(増加/減少) |
| Shift + Arrow | 大きなステップで移動(デフォルト: 10%) |
| Home | 最小位置に移動 |
| End | 最大位置に移動 |
| Enter | プライマリペインの折り畳み/展開を切り替え |
- 矢印キーは向きに基づいて方向が制限されます。水平スプリッターはLeft/Rightのみに、垂直スプリッターはUp/Downのみに応答します。
- RTLモードでは、水平スプリッターでArrowLeftが増加、ArrowRightが減少になります。
- aria-readonlyはrole=“separator”には有効ではありません。読み取り専用の動作はJavaScriptのみで強制する必要があります。
フォーカス管理
| イベント | 振る舞い |
|---|---|
| Tab | スプリッターは通常のタブ順序でフォーカスを受け取る |
| 無効時 | スプリッターはフォーカス不可(tabindex="-1") |
| 読み取り専用時 | スプリッターはフォーカス可能だが操作不可 |
| 折り畳み/展開後 | フォーカスはスプリッターに残る |
参考資料
ソースコード
WindowSplitter.svelte
<script lang="ts">
import { onMount } from 'svelte';
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();
// Constants
const HOVER_DELAY = 300;
const DISMISS_DELAY = 300;
const ACTIVE_SETTLE_DELAY = 500;
const TAP_DISTANCE_THRESHOLD = 5;
const POPUP_OFFSET = 8;
// 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 popupEl: HTMLDivElement | null = $state(null);
// 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;
let popupState = $state<'hidden' | 'showing' | 'active'>('hidden');
let popupPos = $state<{ x: number; y: number } | null>(null);
// Plain variable for synchronous position tracking (for rapid popup button clicks)
let latestPosition = initPosition;
// Timer refs (plain variables, not reactive)
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
let activeSettleTimer: ReturnType<typeof setTimeout> | null = null;
// Pointer tracking
let pointerStart: { x: number; y: number } | null = null;
let isDragging = false;
let isMouseOverPopup = false;
let hoverPos: { x: number; y: number } | null = null;
// 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
);
const splitterLabel = $derived((restProps['aria-label'] as string) || '');
const isAtMin = $derived(position <= min);
const isAtMax = $derived(position >= max);
const icons = $derived(
isVertical
? {
collapse: { d: 'M2 9L6 5L10 9M2 5L6 1L10 5', dx: 0, dy: -1 },
shrink: { d: 'M2 8L6 4L10 8', dx: 0, dy: -1 },
expand: { d: 'M2 4L6 8L10 4', dx: 0, dy: 1 },
max: { d: 'M2 3L6 7L10 3M2 7L6 11L10 7', dx: 0, dy: 1 },
}
: {
collapse: { d: 'M5 2L1 6L5 10M9 2L5 6L9 10', dx: -1, dy: 0 },
shrink: { d: 'M8 2L4 6L8 10', dx: -1, dy: 0 },
expand: { d: 'M4 2L8 6L4 10', dx: 1, dy: 0 },
max: { d: 'M3 2L7 6L3 10M7 2L11 6L7 10', dx: 1, dy: 0 },
}
);
// Timer cleanup
function clearAllTimers() {
if (hoverTimer) clearTimeout(hoverTimer);
if (dismissTimer) clearTimeout(dismissTimer);
if (activeSettleTimer) clearTimeout(activeSettleTimer);
hoverTimer = null;
dismissTimer = null;
activeSettleTimer = null;
}
onMount(() => {
return () => clearAllTimers();
});
$effect(() => {
if (popupState !== 'active') return;
const handleOutsidePointerDown = (e: PointerEvent) => {
if (popupEl && !popupEl.contains(e.target as Node)) {
hidePopup();
}
};
document.addEventListener('pointerdown', handleOutsidePointerDown);
return () => document.removeEventListener('pointerdown', handleOutsidePointerDown);
});
// Calculate popup position
function calcPopupPosition(clientX: number, clientY: number): { x: number; y: number } | null {
if (!splitterEl) return null;
const popupWidth = popupEl?.offsetWidth || (isVertical ? 34 : 120);
const popupHeight = popupEl?.offsetHeight || (isVertical ? 120 : 34);
const vw = window.innerWidth;
const vh = window.innerHeight;
let x: number;
let y: number;
if (isHorizontal) {
x = clientX - popupWidth / 2;
const belowY = clientY + POPUP_OFFSET;
const aboveY = clientY - POPUP_OFFSET - popupHeight;
y = belowY + popupHeight <= vh ? belowY : aboveY;
} else {
y = clientY - popupHeight / 2;
const rightX = clientX + POPUP_OFFSET;
const leftX = clientX - POPUP_OFFSET - popupWidth;
x = rightX + popupWidth <= vw ? rightX : leftX;
}
x = clamp(x, 0, vw - popupWidth);
y = clamp(y, 0, vh - popupHeight);
return { x, y };
}
// Show popup
function showPopup(clientX: number, clientY: number) {
if (disabled || readonly) return;
if (dismissTimer) {
clearTimeout(dismissTimer);
dismissTimer = null;
}
const pos = calcPopupPosition(clientX, clientY);
if (pos) {
popupPos = pos;
popupState = 'showing';
}
}
// Hide popup
function hidePopup() {
clearAllTimers();
popupState = 'hidden';
popupPos = null;
}
// Schedule dismiss
function scheduleDismiss() {
if (dismissTimer) clearTimeout(dismissTimer);
dismissTimer = setTimeout(() => {
const hasFocusInside =
popupEl?.contains(document.activeElement) || splitterEl === document.activeElement;
if (!hasFocusInside) {
hidePopup();
}
}, DISMISS_DELAY);
}
// Update position and emit
function updatePosition(newPosition: number) {
const clampedPosition = clamp(newPosition, min, max);
if (clampedPosition !== latestPosition) {
latestPosition = clampedPosition;
position = clampedPosition;
const sizeInPx = containerEl
? (clampedPosition / 100) *
(isHorizontal ? containerEl.offsetWidth : containerEl.offsetHeight)
: 0;
onpositionchange?.(clampedPosition, sizeInPx);
}
}
// Handle popup button click
function handlePopupButtonClick(delta: number) {
if (disabled || readonly) return;
const currentPos = latestPosition;
const newPos = currentPos + delta;
if (newPos < min || newPos > max) return;
updatePosition(newPos);
popupState = 'active';
if (activeSettleTimer) clearTimeout(activeSettleTimer);
activeSettleTimer = setTimeout(() => {
if (popupState === 'active') {
if (!isMouseOverPopup) {
const pos = hoverPos;
if (pos) {
const newPopupPos = calcPopupPosition(pos.x, pos.y);
if (newPopupPos) popupPos = newPopupPos;
}
}
popupState = 'showing';
}
}, ACTIVE_SETTLE_DELAY);
}
// Handle collapse/expand
function handleToggleCollapse() {
if (!collapsible || disabled || readonly) return;
const currentPos = latestPosition;
if (collapsed) {
// Expand: restore to previous or fallback
const restorePosition = previousPosition ?? expandedPosition ?? defaultPosition ?? 50;
const clampedRestore = clamp(restorePosition, min, max);
oncollapsedchange?.(false, currentPos);
collapsed = false;
latestPosition = clampedRestore;
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 = currentPos;
oncollapsedchange?.(true, currentPos);
collapsed = true;
latestPosition = 0;
position = 0;
onpositionchange?.(0, 0);
}
}
// Keyboard handler
function handleKeyDown(event: KeyboardEvent) {
if (disabled || readonly) return;
// Tab to popup when visible
if (event.key === 'Tab' && !event.shiftKey && popupState !== 'hidden') {
const firstBtn = popupEl?.querySelector<HTMLButtonElement>('button');
if (firstBtn) {
event.preventDefault();
firstBtn.focus();
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(latestPosition + delta);
}
}
}
// Pointer handlers
function handlePointerDown(event: PointerEvent) {
if (disabled || readonly) return;
event.preventDefault();
if (!splitterEl) return;
pointerStart = { x: event.clientX, y: event.clientY };
isDragging = false;
if (typeof splitterEl.setPointerCapture === 'function') {
splitterEl.setPointerCapture(event.pointerId);
}
if (hoverTimer) {
clearTimeout(hoverTimer);
hoverTimer = null;
}
splitterEl.focus();
}
function handlePointerMove(event: PointerEvent) {
const start = pointerStart;
if (!start) return;
if (!isDragging) {
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= TAP_DISTANCE_THRESHOLD) {
isDragging = true;
if (popupState !== 'hidden') hidePopup();
} else {
return;
}
}
if (!containerEl) return;
// Use demo container for stable measurement if available
const demoContainerElement = containerEl.closest('.apg-window-splitter-demo-container');
const demoContainer = demoContainerElement instanceof HTMLElement ? demoContainerElement : 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;
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
}
}
const start = pointerStart;
if (start && !isDragging) {
// Tap detected - show popup
showPopup(start.x, start.y);
}
isDragging = false;
pointerStart = null;
}
// Hover handlers for separator
function handleSeparatorMouseEnter(event: MouseEvent) {
if (disabled || readonly || isDragging) return;
hoverPos = { x: event.clientX, y: event.clientY };
if (popupState === 'hidden') {
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
const pos = hoverPos;
if (pos) showPopup(pos.x, pos.y);
}, HOVER_DELAY);
}
if (dismissTimer) {
clearTimeout(dismissTimer);
dismissTimer = null;
}
}
function handleSeparatorMouseLeave() {
if (hoverTimer) {
clearTimeout(hoverTimer);
hoverTimer = null;
}
if (popupState !== 'hidden') scheduleDismiss();
}
function handleSeparatorMouseMove(event: MouseEvent) {
hoverPos = { x: event.clientX, y: event.clientY };
}
// Focus/blur handlers for separator
function handleSeparatorFocus() {
if (disabled || readonly) return;
if (popupState === 'hidden') {
if (splitterEl) {
const rect = splitterEl.getBoundingClientRect();
showPopup(rect.left + rect.width / 2, rect.top + rect.height / 2);
}
}
if (dismissTimer) {
clearTimeout(dismissTimer);
dismissTimer = null;
}
}
function handleSeparatorBlur() {
if (popupState !== 'hidden') {
setTimeout(() => {
if (!popupEl?.contains(document.activeElement) && splitterEl !== document.activeElement) {
scheduleDismiss();
}
}, 0);
}
}
// Popup mouse handlers
function handlePopupMouseEnter() {
isMouseOverPopup = true;
if (dismissTimer) {
clearTimeout(dismissTimer);
dismissTimer = null;
}
}
function handlePopupMouseLeave() {
isMouseOverPopup = false;
if (popupState !== 'hidden') scheduleDismiss();
}
// Popup keydown handler
function handlePopupKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
hidePopup();
splitterEl?.focus();
}
if (event.key === 'Tab' && event.shiftKey) {
event.preventDefault();
hidePopup();
splitterEl?.focus();
}
}
</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}
onmouseenter={handleSeparatorMouseEnter}
onmouseleave={handleSeparatorMouseLeave}
onmousemove={handleSeparatorMouseMove}
onfocus={handleSeparatorFocus}
onblur={handleSeparatorBlur}
></div>
{#if !disabled && !readonly}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
bind:this={popupEl}
role="group"
aria-label={`Adjust ${splitterLabel}`}
class="apg-window-splitter__popup {popupState !== 'hidden'
? 'apg-window-splitter__popup--visible'
: ''}"
style={popupPos
? `position: fixed; left: ${popupPos.x}px; top: ${popupPos.y}px; flex-direction: ${isVertical ? 'column' : 'row'}`
: undefined}
onmouseenter={handlePopupMouseEnter}
onmouseleave={handlePopupMouseLeave}
onkeydown={handlePopupKeyDown}
>
<button
type="button"
class="apg-window-splitter__popup-button"
tabindex={-1}
aria-label={`Collapse ${splitterLabel}`}
aria-disabled={isAtMin}
onclick={() => !isAtMin && handlePopupButtonClick(min - latestPosition)}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style="transform: translate({icons.collapse.dx}px, {icons.collapse.dy}px)"
><path d={icons.collapse.d} /></svg
>
</button>
<button
type="button"
class="apg-window-splitter__popup-button"
tabindex={-1}
aria-label={`Shrink ${splitterLabel}`}
aria-disabled={isAtMin}
onclick={() => !isAtMin && handlePopupButtonClick(-step)}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style="transform: translate({icons.shrink.dx}px, {icons.shrink.dy}px)"
><path d={icons.shrink.d} /></svg
>
</button>
<button
type="button"
class="apg-window-splitter__popup-button"
tabindex={-1}
aria-label={`Expand ${splitterLabel}`}
aria-disabled={isAtMax}
onclick={() => !isAtMax && handlePopupButtonClick(step)}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style="transform: translate({icons.expand.dx}px, {icons.expand.dy}px)"
><path d={icons.expand.d} /></svg
>
</button>
<button
type="button"
class="apg-window-splitter__popup-button"
tabindex={-1}
aria-label={`Expand ${splitterLabel} to maximum`}
aria-disabled={isAtMax}
onclick={() => !isAtMax && handlePopupButtonClick(max - latestPosition)}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style="transform: translate({icons.max.dx}px, {icons.max.dy}px)"
><path d={icons.max.d} /></svg
>
</button>
</div>
{/if}
</div> 使い方
Example
<script lang="ts">
import WindowSplitter from './WindowSplitter.svelte';
function handlePositionChange(position: number, sizeInPx: number) {
console.log('Position:', position, 'Size:', 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
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
primaryPaneId | string | required | プライマリペインのID(aria-controls用) |
secondaryPaneId | string | - | セカンダリペインのID(任意) |
defaultPosition | number | 50 | 初期位置(パーセンテージ 0-100) |
orientation | 'horizontal' | 'vertical' | 'horizontal' | スプリッターの向き |
disabled | boolean | false | 無効状態 |
readonly | boolean | false | 読み取り専用状態 |
Custom Events
| イベント | Detail | 説明 |
|---|---|---|
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 | 垂直スプリッターを移動(増加/減少) |
Direction restriction | 誤った方向のキーは効果なし |
Shift+Arrow | 大きなステップで移動 |
Home/End | 最小/最大位置に移動 |
Enter (collapse) | 0に折り畳み |
Enter (expand) | 以前の位置を復元 |
RTL support | RTLモードでArrowLeft/Rightが反転 |
高優先度: フォーカス管理
| テスト | 説明 |
|---|---|
tabindex="0" | スプリッターがフォーカス可能 |
tabindex="-1" | 無効なスプリッターはフォーカス不可 |
readonly focusable | 読み取り専用スプリッターはフォーカス可能だが操作不可 |
Focus after collapse | フォーカスはスプリッターに残る |
中優先度: ポインターインタラクション
| テスト | 説明 |
|---|---|
Drag to resize | ドラッグ中に位置が更新される |
Focus on click | クリックでスプリッターにフォーカス |
Disabled no response | 無効なスプリッターはポインターを無視 |
Readonly no response | 読み取り専用スプリッターはポインターを無視 |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe violations | WCAG 2.1 AA違反なし |
Collapsed state | 折り畳み時に違反なし |
Disabled state | 無効時に違反なし |
テストの実行
ユニットテスト
# Run all Window Splitter unit tests
npx vitest run src/patterns/windowsplitter/
# Run framework-specific tests
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テスト
# Run all Window Splitter E2E tests
npm run test:e2e -- windowsplitter.spec.ts
# Run in UI mode
npm run test:e2e:ui -- windowsplitter.spec.ts テストツール
- Vitest (opens in new tab) - ユニットテストランナー
- Testing Library (opens in new tab) - フレームワーク別テストユーティリティ
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core (opens in new tab) - アクセシビリティテストエンジン
詳細は テスト戦略 ガイドを参照してください。
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('moves separator up 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', '45');
});
it('moves separator down 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', '55');
});
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