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.tsx
import { clsx } from 'clsx';
import type { CSSProperties } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface SplitterStyle extends CSSProperties {
'--splitter-position': string;
}
type LabelProps =
| { 'aria-label': string; 'aria-labelledby'?: never }
| { 'aria-label'?: never; 'aria-labelledby': string };
type WindowSplitterBaseProps = {
primaryPaneId: string;
secondaryPaneId?: string;
defaultPosition?: number;
defaultCollapsed?: boolean;
expandedPosition?: number;
min?: number;
max?: number;
step?: number;
largeStep?: number;
orientation?: 'horizontal' | 'vertical';
dir?: 'ltr' | 'rtl';
collapsible?: boolean;
disabled?: boolean;
readonly?: boolean;
onPositionChange?: (position: number, sizeInPx: number) => void;
onCollapsedChange?: (collapsed: boolean, previousPosition: number) => void;
'aria-describedby'?: string;
'data-testid'?: string;
className?: string;
id?: string;
};
export type WindowSplitterProps = WindowSplitterBaseProps & LabelProps;
const clamp = (value: number, min: number, max: number): number => {
return Math.min(max, Math.max(min, value));
};
const ChevronIcon = ({ d, dx = 0, dy = 0 }: { d: string; dx?: number; dy?: number }) => (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
style={dx || dy ? { transform: `translate(${dx}px, ${dy}px)` } : undefined}
>
<path d={d} />
</svg>
);
const HOVER_DELAY = 300;
const DISMISS_DELAY = 300;
const ACTIVE_SETTLE_DELAY = 500;
const TAP_DISTANCE_THRESHOLD = 5;
const POPUP_OFFSET = 8;
type PopupState = 'hidden' | 'showing' | 'active';
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,
}) => {
const initialPosition = defaultCollapsed ? 0 : clamp(defaultPosition, min, max);
const [position, setPosition] = useState(initialPosition);
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const [popupState, setPopupState] = useState<PopupState>('hidden');
const [popupPos, setPopupPos] = useState<{ x: number; y: number } | null>(null);
const splitterRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const previousPositionRef = useRef<number | null>(defaultCollapsed ? null : initialPosition);
const positionRef = useRef(initialPosition);
const isDraggingRef = useRef(false);
const isMouseOverPopupRef = useRef(false);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
const hoverPosRef = useRef<{ x: number; y: number } | null>(null);
const isHorizontal = orientation === 'horizontal';
const isVertical = orientation === 'vertical';
const isRTL =
dir === 'rtl' ||
(dir === undefined && typeof document !== 'undefined' && document.dir === 'rtl');
const splitterLabel = ariaLabel || '';
const icons = 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 },
};
const clearAllTimers = useCallback(() => {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
if (activeSettleTimerRef.current) clearTimeout(activeSettleTimerRef.current);
hoverTimerRef.current = null;
dismissTimerRef.current = null;
activeSettleTimerRef.current = null;
}, []);
useEffect(() => {
return () => clearAllTimers();
}, [clearAllTimers]);
const calcPopupPosition = useCallback(
(clientX: number, clientY: number) => {
if (!splitterRef.current) return null;
const popupEl = popupRef.current;
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 };
},
[isHorizontal, isVertical]
);
const showPopup = useCallback(
(clientX: number, clientY: number) => {
if (disabled || readonly) return;
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = null;
}
const pos = calcPopupPosition(clientX, clientY);
if (pos) {
setPopupPos(pos);
setPopupState('showing');
}
},
[disabled, readonly, calcPopupPosition]
);
const hidePopup = useCallback(() => {
clearAllTimers();
setPopupState('hidden');
setPopupPos(null);
}, [clearAllTimers]);
useEffect(() => {
if (popupState !== 'active') return;
const handleOutsidePointerDown = (e: PointerEvent) => {
const popup = popupRef.current;
if (popup && e.target instanceof Node && !popup.contains(e.target)) {
hidePopup();
}
};
document.addEventListener('pointerdown', handleOutsidePointerDown);
return () => document.removeEventListener('pointerdown', handleOutsidePointerDown);
}, [popupState, hidePopup]);
const scheduleDismiss = useCallback(() => {
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = setTimeout(() => {
const splitter = splitterRef.current;
const popup = popupRef.current;
const hasFocusInside =
popup?.contains(document.activeElement) || splitter === document.activeElement;
if (!hasFocusInside) {
hidePopup();
}
}, DISMISS_DELAY);
}, [hidePopup]);
const updatePosition = useCallback(
(newPosition: number) => {
const clampedPosition = clamp(newPosition, min, max);
if (clampedPosition !== positionRef.current) {
positionRef.current = clampedPosition;
setPosition(clampedPosition);
const container = containerRef.current;
const sizeInPx = container
? (clampedPosition / 100) *
(isHorizontal ? container.offsetWidth : container.offsetHeight)
: 0;
onPositionChange?.(clampedPosition, sizeInPx);
}
},
[min, max, isHorizontal, onPositionChange]
);
const handlePopupButtonClick = useCallback(
(delta: number) => {
if (disabled || readonly) return;
const currentPos = positionRef.current;
const newPos = currentPos + delta;
if (newPos < min || newPos > max) return;
updatePosition(newPos);
setPopupState('active');
if (activeSettleTimerRef.current) clearTimeout(activeSettleTimerRef.current);
activeSettleTimerRef.current = setTimeout(() => {
setPopupState((prev) => {
if (prev === 'active') {
if (!isMouseOverPopupRef.current) {
const pos = hoverPosRef.current;
if (pos) {
const newPopupPos = calcPopupPosition(pos.x, pos.y);
if (newPopupPos) setPopupPos(newPopupPos);
}
}
return 'showing';
}
return prev;
});
}, ACTIVE_SETTLE_DELAY);
},
[disabled, readonly, min, max, updatePosition, calcPopupPosition]
);
const handleToggleCollapse = useCallback(() => {
if (!collapsible || disabled || readonly) return;
const currentPos = positionRef.current;
if (collapsed) {
const restorePosition =
previousPositionRef.current ?? expandedPosition ?? defaultPosition ?? 50;
const clampedRestore = clamp(restorePosition, min, max);
onCollapsedChange?.(false, currentPos);
setCollapsed(false);
positionRef.current = clampedRestore;
setPosition(clampedRestore);
const container = containerRef.current;
const sizeInPx = container
? (clampedRestore / 100) * (isHorizontal ? container.offsetWidth : container.offsetHeight)
: 0;
onPositionChange?.(clampedRestore, sizeInPx);
} else {
previousPositionRef.current = currentPos;
onCollapsedChange?.(true, currentPos);
setCollapsed(true);
positionRef.current = 0;
setPosition(0);
onPositionChange?.(0, 0);
}
}, [
collapsed,
collapsible,
disabled,
readonly,
expandedPosition,
defaultPosition,
min,
max,
isHorizontal,
onCollapsedChange,
onPositionChange,
]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (disabled || readonly) return;
if (event.key === 'Tab' && !event.shiftKey && popupState !== 'hidden') {
const firstBtn = popupRef.current?.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(positionRef.current + delta);
}
}
},
[
disabled,
readonly,
popupState,
isHorizontal,
isVertical,
isRTL,
step,
largeStep,
min,
max,
handleToggleCollapse,
updatePosition,
]
);
const handlePointerDown = useCallback(
(event: React.PointerEvent) => {
if (disabled || readonly) return;
event.preventDefault();
const splitter = splitterRef.current;
if (!splitter) return;
pointerStartRef.current = { x: event.clientX, y: event.clientY };
isDraggingRef.current = false;
if (typeof splitter.setPointerCapture === 'function') {
splitter.setPointerCapture(event.pointerId);
}
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
splitter.focus();
},
[disabled, readonly]
);
const handlePointerMove = useCallback(
(event: React.PointerEvent) => {
const start = pointerStartRef.current;
if (!start) return;
if (!isDraggingRef.current) {
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) {
isDraggingRef.current = true;
if (popupState !== 'hidden') hidePopup();
} else {
return;
}
}
const container = containerRef.current;
if (!container) return;
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;
percent = (y / rect.height) * 100;
}
const clampedPercent = clamp(percent, min, max);
if (demoContainer) {
demoContainer.style.setProperty('--splitter-position', `${clampedPercent}%`);
}
updatePosition(percent);
},
[isHorizontal, min, max, updatePosition, popupState, hidePopup]
);
const handlePointerUp = useCallback(
(event: React.PointerEvent) => {
const splitter = splitterRef.current;
if (splitter && typeof splitter.releasePointerCapture === 'function') {
try {
splitter.releasePointerCapture(event.pointerId);
} catch {
// Ignore
}
}
const start = pointerStartRef.current;
if (start && !isDraggingRef.current) {
showPopup(start.x, start.y);
}
isDraggingRef.current = false;
pointerStartRef.current = null;
},
[showPopup]
);
const handleSeparatorMouseEnter = useCallback(
(event: React.MouseEvent) => {
if (disabled || readonly || isDraggingRef.current) return;
hoverPosRef.current = { x: event.clientX, y: event.clientY };
if (popupState === 'hidden') {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = setTimeout(() => {
const pos = hoverPosRef.current;
if (pos) showPopup(pos.x, pos.y);
}, HOVER_DELAY);
}
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = null;
}
},
[disabled, readonly, popupState, showPopup]
);
const handleSeparatorMouseLeave = useCallback(() => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
if (popupState !== 'hidden') scheduleDismiss();
}, [popupState, scheduleDismiss]);
const handleSeparatorMouseMove = useCallback((event: React.MouseEvent) => {
hoverPosRef.current = { x: event.clientX, y: event.clientY };
}, []);
const handleSeparatorFocus = useCallback(() => {
if (disabled || readonly) return;
if (popupState === 'hidden') {
const splitter = splitterRef.current;
if (splitter) {
const rect = splitter.getBoundingClientRect();
showPopup(rect.left + rect.width / 2, rect.top + rect.height / 2);
}
}
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = null;
}
}, [disabled, readonly, popupState, showPopup]);
const handleSeparatorBlur = useCallback(() => {
if (popupState !== 'hidden') {
setTimeout(() => {
const popup = popupRef.current;
const splitter = splitterRef.current;
if (!popup?.contains(document.activeElement) && splitter !== document.activeElement) {
scheduleDismiss();
}
}, 0);
}
}, [popupState, scheduleDismiss]);
const handlePopupMouseEnter = useCallback(() => {
isMouseOverPopupRef.current = true;
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = null;
}
}, []);
const handlePopupMouseLeave = useCallback(() => {
isMouseOverPopupRef.current = false;
if (popupState !== 'hidden') scheduleDismiss();
}, [popupState, scheduleDismiss]);
const handlePopupKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
hidePopup();
splitterRef.current?.focus();
}
if (event.key === 'Tab' && event.shiftKey) {
event.preventDefault();
hidePopup();
splitterRef.current?.focus();
}
},
[hidePopup]
);
const ariaControls = secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId;
const isAtMin = position <= min;
const isAtMax = position >= max;
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}
>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<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}
onMouseEnter={handleSeparatorMouseEnter}
onMouseLeave={handleSeparatorMouseLeave}
onMouseMove={handleSeparatorMouseMove}
onFocus={handleSeparatorFocus}
onBlur={handleSeparatorBlur}
/>
{!disabled && !readonly && (
/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */
<div
ref={popupRef}
role="group"
aria-label={`Adjust ${splitterLabel}`}
className={clsx(
'apg-window-splitter__popup',
popupState !== 'hidden' && 'apg-window-splitter__popup--visible'
)}
style={
popupPos
? {
left: popupPos.x,
top: popupPos.y,
flexDirection: isVertical ? ('column' as const) : ('row' as const),
}
: undefined
}
onMouseEnter={handlePopupMouseEnter}
onMouseLeave={handlePopupMouseLeave}
onKeyDown={handlePopupKeyDown}
>
<button
type="button"
className="apg-window-splitter__popup-button"
tabIndex={-1}
aria-label={`Collapse ${splitterLabel}`}
aria-disabled={isAtMin}
onClick={() => !isAtMin && handlePopupButtonClick(min - positionRef.current)}
>
<ChevronIcon {...icons.collapse} />
</button>
<button
type="button"
className="apg-window-splitter__popup-button"
tabIndex={-1}
aria-label={`Shrink ${splitterLabel}`}
aria-disabled={isAtMin}
onClick={() => !isAtMin && handlePopupButtonClick(-step)}
>
<ChevronIcon {...icons.shrink} />
</button>
<button
type="button"
className="apg-window-splitter__popup-button"
tabIndex={-1}
aria-label={`Expand ${splitterLabel}`}
aria-disabled={isAtMax}
onClick={() => !isAtMax && handlePopupButtonClick(step)}
>
<ChevronIcon {...icons.expand} />
</button>
<button
type="button"
className="apg-window-splitter__popup-button"
tabIndex={-1}
aria-label={`Expand ${splitterLabel} to maximum`}
aria-disabled={isAtMax}
onClick={() => !isAtMax && handlePopupButtonClick(max - positionRef.current)}
>
<ChevronIcon {...icons.max} />
</button>
</div>
)}
</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
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
primaryPaneId | string | required | プライマリペインの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 | 垂直スプリッターを移動(増加/減少) |
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.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('moves separator up 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', '45');
});
it('moves separator down 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', '55');
});
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('moves separator up 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', '40');
});
});
// 🔴 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