APG Patterns
English
English

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

テストツール

詳細は テスト戦略 ガイドを参照してください。

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');
    });
  });
});

リソース