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.svelte
<script lang="ts">
  import { onMount } from 'svelte';

  interface WindowSplitterProps {
    /** Primary pane ID (required for aria-controls) */
    primaryPaneId: string;
    /** Secondary pane ID (optional, added to aria-controls) */
    secondaryPaneId?: string;
    /** Initial position as % (0-100, default: 50) */
    defaultPosition?: number;
    /** Initial collapsed state (default: false) */
    defaultCollapsed?: boolean;
    /** Position when expanding from initial collapse */
    expandedPosition?: number;
    /** Minimum position as % (default: 10) */
    min?: number;
    /** Maximum position as % (default: 90) */
    max?: number;
    /** Keyboard step as % (default: 5) */
    step?: number;
    /** Shift+Arrow step as % (default: 10) */
    largeStep?: number;
    /** Splitter orientation (default: horizontal = left-right split) */
    orientation?: 'horizontal' | 'vertical';
    /** Text direction for RTL support */
    dir?: 'ltr' | 'rtl';
    /** Whether pane can be collapsed (default: true) */
    collapsible?: boolean;
    /** Disabled state (not focusable, not operable) */
    disabled?: boolean;
    /** Readonly state (focusable but not operable) */
    readonly?: boolean;
    /** Callback when position changes */
    onpositionchange?: (position: number, sizeInPx: number) => void;
    /** Callback when collapsed state changes */
    oncollapsedchange?: (collapsed: boolean, previousPosition: number) => void;
    [key: string]: unknown;
  }

  let {
    primaryPaneId,
    secondaryPaneId,
    defaultPosition = 50,
    defaultCollapsed = false,
    expandedPosition,
    min = 10,
    max = 90,
    step = 5,
    largeStep = 10,
    orientation = 'horizontal',
    dir,
    collapsible = true,
    disabled = false,
    readonly = false,
    onpositionchange,
    oncollapsedchange,
    ...restProps
  }: WindowSplitterProps = $props();

  // Constants
  const HOVER_DELAY = 300;
  const DISMISS_DELAY = 300;
  const ACTIVE_SETTLE_DELAY = 500;
  const TAP_DISTANCE_THRESHOLD = 5;
  const POPUP_OFFSET = 8;

  // Utility function
  function clamp(value: number, minVal: number, maxVal: number): number {
    return Math.min(maxVal, Math.max(minVal, value));
  }

  // Refs
  let splitterEl: HTMLDivElement | null = null;
  let containerEl: HTMLDivElement | null = null;
  let popupEl: HTMLDivElement | null = $state(null);

  // State - capture initial prop values (intentionally not reactive to prop changes)
  // Using IIFE to avoid Svelte's state_referenced_locally warning
  const { initPosition, initCollapsed, initPreviousPosition } = (() => {
    const collapsed = defaultCollapsed;
    const pos = collapsed ? 0 : clamp(defaultPosition, min, max);
    const prevPos = collapsed ? null : clamp(defaultPosition, min, max);
    return { initPosition: pos, initCollapsed: collapsed, initPreviousPosition: prevPos };
  })();
  let position = $state(initPosition);
  let collapsed = $state(initCollapsed);
  let previousPosition: number | null = initPreviousPosition;
  let popupState = $state<'hidden' | 'showing' | 'active'>('hidden');
  let popupPos = $state<{ x: number; y: number } | null>(null);

  // Plain variable for synchronous position tracking (for rapid popup button clicks)
  let latestPosition = initPosition;

  // Timer refs (plain variables, not reactive)
  let hoverTimer: ReturnType<typeof setTimeout> | null = null;
  let dismissTimer: ReturnType<typeof setTimeout> | null = null;
  let activeSettleTimer: ReturnType<typeof setTimeout> | null = null;

  // Pointer tracking
  let pointerStart: { x: number; y: number } | null = null;
  let isDragging = false;
  let isMouseOverPopup = false;
  let hoverPos: { x: number; y: number } | null = null;

  // Computed
  const isVertical = $derived(orientation === 'vertical');
  const isHorizontal = $derived(orientation === 'horizontal');

  const isRTL = $derived(
    dir === 'rtl' || (dir !== 'ltr' && typeof document !== 'undefined' && document.dir === 'rtl')
  );

  const ariaControls = $derived(
    secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId
  );

  const splitterLabel = $derived((restProps['aria-label'] as string) || '');

  const isAtMin = $derived(position <= min);
  const isAtMax = $derived(position >= max);

  const icons = $derived(
    isVertical
      ? {
          collapse: { d: 'M2 9L6 5L10 9M2 5L6 1L10 5', dx: 0, dy: -1 },
          shrink: { d: 'M2 8L6 4L10 8', dx: 0, dy: -1 },
          expand: { d: 'M2 4L6 8L10 4', dx: 0, dy: 1 },
          max: { d: 'M2 3L6 7L10 3M2 7L6 11L10 7', dx: 0, dy: 1 },
        }
      : {
          collapse: { d: 'M5 2L1 6L5 10M9 2L5 6L9 10', dx: -1, dy: 0 },
          shrink: { d: 'M8 2L4 6L8 10', dx: -1, dy: 0 },
          expand: { d: 'M4 2L8 6L4 10', dx: 1, dy: 0 },
          max: { d: 'M3 2L7 6L3 10M7 2L11 6L7 10', dx: 1, dy: 0 },
        }
  );

  // Timer cleanup
  function clearAllTimers() {
    if (hoverTimer) clearTimeout(hoverTimer);
    if (dismissTimer) clearTimeout(dismissTimer);
    if (activeSettleTimer) clearTimeout(activeSettleTimer);
    hoverTimer = null;
    dismissTimer = null;
    activeSettleTimer = null;
  }

  onMount(() => {
    return () => clearAllTimers();
  });

  $effect(() => {
    if (popupState !== 'active') return;
    const handleOutsidePointerDown = (e: PointerEvent) => {
      if (popupEl && !popupEl.contains(e.target as Node)) {
        hidePopup();
      }
    };
    document.addEventListener('pointerdown', handleOutsidePointerDown);
    return () => document.removeEventListener('pointerdown', handleOutsidePointerDown);
  });

  // Calculate popup position
  function calcPopupPosition(clientX: number, clientY: number): { x: number; y: number } | null {
    if (!splitterEl) return null;
    const popupWidth = popupEl?.offsetWidth || (isVertical ? 34 : 120);
    const popupHeight = popupEl?.offsetHeight || (isVertical ? 120 : 34);
    const vw = window.innerWidth;
    const vh = window.innerHeight;

    let x: number;
    let y: number;

    if (isHorizontal) {
      x = clientX - popupWidth / 2;
      const belowY = clientY + POPUP_OFFSET;
      const aboveY = clientY - POPUP_OFFSET - popupHeight;
      y = belowY + popupHeight <= vh ? belowY : aboveY;
    } else {
      y = clientY - popupHeight / 2;
      const rightX = clientX + POPUP_OFFSET;
      const leftX = clientX - POPUP_OFFSET - popupWidth;
      x = rightX + popupWidth <= vw ? rightX : leftX;
    }

    x = clamp(x, 0, vw - popupWidth);
    y = clamp(y, 0, vh - popupHeight);

    return { x, y };
  }

  // Show popup
  function showPopup(clientX: number, clientY: number) {
    if (disabled || readonly) return;
    if (dismissTimer) {
      clearTimeout(dismissTimer);
      dismissTimer = null;
    }
    const pos = calcPopupPosition(clientX, clientY);
    if (pos) {
      popupPos = pos;
      popupState = 'showing';
    }
  }

  // Hide popup
  function hidePopup() {
    clearAllTimers();
    popupState = 'hidden';
    popupPos = null;
  }

  // Schedule dismiss
  function scheduleDismiss() {
    if (dismissTimer) clearTimeout(dismissTimer);
    dismissTimer = setTimeout(() => {
      const hasFocusInside =
        popupEl?.contains(document.activeElement) || splitterEl === document.activeElement;
      if (!hasFocusInside) {
        hidePopup();
      }
    }, DISMISS_DELAY);
  }

  // Update position and emit
  function updatePosition(newPosition: number) {
    const clampedPosition = clamp(newPosition, min, max);
    if (clampedPosition !== latestPosition) {
      latestPosition = clampedPosition;
      position = clampedPosition;

      const sizeInPx = containerEl
        ? (clampedPosition / 100) *
          (isHorizontal ? containerEl.offsetWidth : containerEl.offsetHeight)
        : 0;

      onpositionchange?.(clampedPosition, sizeInPx);
    }
  }

  // Handle popup button click
  function handlePopupButtonClick(delta: number) {
    if (disabled || readonly) return;
    const currentPos = latestPosition;
    const newPos = currentPos + delta;
    if (newPos < min || newPos > max) return;
    updatePosition(newPos);
    popupState = 'active';
    if (activeSettleTimer) clearTimeout(activeSettleTimer);
    activeSettleTimer = setTimeout(() => {
      if (popupState === 'active') {
        if (!isMouseOverPopup) {
          const pos = hoverPos;
          if (pos) {
            const newPopupPos = calcPopupPosition(pos.x, pos.y);
            if (newPopupPos) popupPos = newPopupPos;
          }
        }
        popupState = 'showing';
      }
    }, ACTIVE_SETTLE_DELAY);
  }

  // Handle collapse/expand
  function handleToggleCollapse() {
    if (!collapsible || disabled || readonly) return;

    const currentPos = latestPosition;

    if (collapsed) {
      // Expand: restore to previous or fallback
      const restorePosition = previousPosition ?? expandedPosition ?? defaultPosition ?? 50;
      const clampedRestore = clamp(restorePosition, min, max);

      oncollapsedchange?.(false, currentPos);
      collapsed = false;
      latestPosition = clampedRestore;
      position = clampedRestore;

      const sizeInPx = containerEl
        ? (clampedRestore / 100) *
          (isHorizontal ? containerEl.offsetWidth : containerEl.offsetHeight)
        : 0;
      onpositionchange?.(clampedRestore, sizeInPx);
    } else {
      // Collapse: save current position, set to 0
      previousPosition = currentPos;
      oncollapsedchange?.(true, currentPos);
      collapsed = true;
      latestPosition = 0;
      position = 0;
      onpositionchange?.(0, 0);
    }
  }

  // Keyboard handler
  function handleKeyDown(event: KeyboardEvent) {
    if (disabled || readonly) return;

    // Tab to popup when visible
    if (event.key === 'Tab' && !event.shiftKey && popupState !== 'hidden') {
      const firstBtn = popupEl?.querySelector<HTMLButtonElement>('button');
      if (firstBtn) {
        event.preventDefault();
        firstBtn.focus();
        return;
      }
    }

    const hasShift = event.shiftKey;
    const currentStep = hasShift ? largeStep : step;

    let delta = 0;
    let handled = false;

    switch (event.key) {
      case 'ArrowRight':
        if (!isHorizontal) break;
        delta = isRTL ? -currentStep : currentStep;
        handled = true;
        break;

      case 'ArrowLeft':
        if (!isHorizontal) break;
        delta = isRTL ? currentStep : -currentStep;
        handled = true;
        break;

      case 'ArrowUp':
        if (!isVertical) break;
        delta = -currentStep;
        handled = true;
        break;

      case 'ArrowDown':
        if (!isVertical) break;
        delta = currentStep;
        handled = true;
        break;

      case 'Enter':
        handleToggleCollapse();
        handled = true;
        break;

      case 'Home':
        updatePosition(min);
        handled = true;
        break;

      case 'End':
        updatePosition(max);
        handled = true;
        break;
    }

    if (handled) {
      event.preventDefault();
      if (delta !== 0) {
        updatePosition(latestPosition + delta);
      }
    }
  }

  // Pointer handlers
  function handlePointerDown(event: PointerEvent) {
    if (disabled || readonly) return;

    event.preventDefault();
    if (!splitterEl) return;

    pointerStart = { x: event.clientX, y: event.clientY };
    isDragging = false;

    if (typeof splitterEl.setPointerCapture === 'function') {
      splitterEl.setPointerCapture(event.pointerId);
    }

    if (hoverTimer) {
      clearTimeout(hoverTimer);
      hoverTimer = null;
    }

    splitterEl.focus();
  }

  function handlePointerMove(event: PointerEvent) {
    const start = pointerStart;
    if (!start) return;

    if (!isDragging) {
      const dx = event.clientX - start.x;
      const dy = event.clientY - start.y;
      const distance = Math.sqrt(dx * dx + dy * dy);
      if (distance >= TAP_DISTANCE_THRESHOLD) {
        isDragging = true;
        if (popupState !== 'hidden') hidePopup();
      } else {
        return;
      }
    }

    if (!containerEl) return;

    // Use demo container for stable measurement if available
    const demoContainerElement = containerEl.closest('.apg-window-splitter-demo-container');
    const demoContainer = demoContainerElement instanceof HTMLElement ? demoContainerElement : null;
    const measureElement = demoContainer || containerEl.parentElement || containerEl;
    const rect = measureElement.getBoundingClientRect();

    let percent: number;
    if (isHorizontal) {
      const x = event.clientX - rect.left;
      percent = (x / rect.width) * 100;
    } else {
      const y = event.clientY - rect.top;
      percent = (y / rect.height) * 100;
    }

    // Clamp the percent to min/max
    const clampedPercent = clamp(percent, min, max);

    // Update CSS variable directly for smooth dragging
    if (demoContainer) {
      demoContainer.style.setProperty('--splitter-position', `${clampedPercent}%`);
    }

    updatePosition(percent);
  }

  function handlePointerUp(event: PointerEvent) {
    if (splitterEl && typeof splitterEl.releasePointerCapture === 'function') {
      try {
        splitterEl.releasePointerCapture(event.pointerId);
      } catch {
        // Ignore
      }
    }

    const start = pointerStart;
    if (start && !isDragging) {
      // Tap detected - show popup
      showPopup(start.x, start.y);
    }

    isDragging = false;
    pointerStart = null;
  }

  // Hover handlers for separator
  function handleSeparatorMouseEnter(event: MouseEvent) {
    if (disabled || readonly || isDragging) return;
    hoverPos = { x: event.clientX, y: event.clientY };
    if (popupState === 'hidden') {
      if (hoverTimer) clearTimeout(hoverTimer);
      hoverTimer = setTimeout(() => {
        const pos = hoverPos;
        if (pos) showPopup(pos.x, pos.y);
      }, HOVER_DELAY);
    }
    if (dismissTimer) {
      clearTimeout(dismissTimer);
      dismissTimer = null;
    }
  }

  function handleSeparatorMouseLeave() {
    if (hoverTimer) {
      clearTimeout(hoverTimer);
      hoverTimer = null;
    }
    if (popupState !== 'hidden') scheduleDismiss();
  }

  function handleSeparatorMouseMove(event: MouseEvent) {
    hoverPos = { x: event.clientX, y: event.clientY };
  }

  // Focus/blur handlers for separator
  function handleSeparatorFocus() {
    if (disabled || readonly) return;
    if (popupState === 'hidden') {
      if (splitterEl) {
        const rect = splitterEl.getBoundingClientRect();
        showPopup(rect.left + rect.width / 2, rect.top + rect.height / 2);
      }
    }
    if (dismissTimer) {
      clearTimeout(dismissTimer);
      dismissTimer = null;
    }
  }

  function handleSeparatorBlur() {
    if (popupState !== 'hidden') {
      setTimeout(() => {
        if (!popupEl?.contains(document.activeElement) && splitterEl !== document.activeElement) {
          scheduleDismiss();
        }
      }, 0);
    }
  }

  // Popup mouse handlers
  function handlePopupMouseEnter() {
    isMouseOverPopup = true;
    if (dismissTimer) {
      clearTimeout(dismissTimer);
      dismissTimer = null;
    }
  }

  function handlePopupMouseLeave() {
    isMouseOverPopup = false;
    if (popupState !== 'hidden') scheduleDismiss();
  }

  // Popup keydown handler
  function handlePopupKeyDown(event: KeyboardEvent) {
    if (event.key === 'Escape') {
      event.preventDefault();
      hidePopup();
      splitterEl?.focus();
    }
    if (event.key === 'Tab' && event.shiftKey) {
      event.preventDefault();
      hidePopup();
      splitterEl?.focus();
    }
  }
</script>

<div
  bind:this={containerEl}
  class="apg-window-splitter {isVertical ? 'apg-window-splitter--vertical' : ''} {disabled
    ? 'apg-window-splitter--disabled'
    : ''} {restProps.class || ''}"
  style="--splitter-position: {position}%"
>
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
  <!-- role="separator" with aria-valuenow is a focusable widget per WAI-ARIA spec -->
  <div
    bind:this={splitterEl}
    role="separator"
    id={restProps.id}
    tabindex={disabled ? -1 : 0}
    aria-valuenow={position}
    aria-valuemin={min}
    aria-valuemax={max}
    aria-controls={ariaControls}
    aria-orientation={isVertical ? 'vertical' : undefined}
    aria-disabled={disabled ? true : undefined}
    aria-label={restProps['aria-label']}
    aria-labelledby={restProps['aria-labelledby']}
    aria-describedby={restProps['aria-describedby']}
    data-testid={restProps['data-testid']}
    class="apg-window-splitter__separator"
    onkeydown={handleKeyDown}
    onpointerdown={handlePointerDown}
    onpointermove={handlePointerMove}
    onpointerup={handlePointerUp}
    onmouseenter={handleSeparatorMouseEnter}
    onmouseleave={handleSeparatorMouseLeave}
    onmousemove={handleSeparatorMouseMove}
    onfocus={handleSeparatorFocus}
    onblur={handleSeparatorBlur}
  ></div>
  {#if !disabled && !readonly}
    <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
    <div
      bind:this={popupEl}
      role="group"
      aria-label={`Adjust ${splitterLabel}`}
      class="apg-window-splitter__popup {popupState !== 'hidden'
        ? 'apg-window-splitter__popup--visible'
        : ''}"
      style={popupPos
        ? `position: fixed; left: ${popupPos.x}px; top: ${popupPos.y}px; flex-direction: ${isVertical ? 'column' : 'row'}`
        : undefined}
      onmouseenter={handlePopupMouseEnter}
      onmouseleave={handlePopupMouseLeave}
      onkeydown={handlePopupKeyDown}
    >
      <button
        type="button"
        class="apg-window-splitter__popup-button"
        tabindex={-1}
        aria-label={`Collapse ${splitterLabel}`}
        aria-disabled={isAtMin}
        onclick={() => !isAtMin && handlePopupButtonClick(min - latestPosition)}
      >
        <svg
          width="12"
          height="12"
          viewBox="0 0 12 12"
          fill="none"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
          style="transform: translate({icons.collapse.dx}px, {icons.collapse.dy}px)"
          ><path d={icons.collapse.d} /></svg
        >
      </button>
      <button
        type="button"
        class="apg-window-splitter__popup-button"
        tabindex={-1}
        aria-label={`Shrink ${splitterLabel}`}
        aria-disabled={isAtMin}
        onclick={() => !isAtMin && handlePopupButtonClick(-step)}
      >
        <svg
          width="12"
          height="12"
          viewBox="0 0 12 12"
          fill="none"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
          style="transform: translate({icons.shrink.dx}px, {icons.shrink.dy}px)"
          ><path d={icons.shrink.d} /></svg
        >
      </button>
      <button
        type="button"
        class="apg-window-splitter__popup-button"
        tabindex={-1}
        aria-label={`Expand ${splitterLabel}`}
        aria-disabled={isAtMax}
        onclick={() => !isAtMax && handlePopupButtonClick(step)}
      >
        <svg
          width="12"
          height="12"
          viewBox="0 0 12 12"
          fill="none"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
          style="transform: translate({icons.expand.dx}px, {icons.expand.dy}px)"
          ><path d={icons.expand.d} /></svg
        >
      </button>
      <button
        type="button"
        class="apg-window-splitter__popup-button"
        tabindex={-1}
        aria-label={`Expand ${splitterLabel} to maximum`}
        aria-disabled={isAtMax}
        onclick={() => !isAtMax && handlePopupButtonClick(max - latestPosition)}
      >
        <svg
          width="12"
          height="12"
          viewBox="0 0 12 12"
          fill="none"
          stroke="currentColor"
          stroke-width="1.5"
          stroke-linecap="round"
          stroke-linejoin="round"
          aria-hidden="true"
          style="transform: translate({icons.max.dx}px, {icons.max.dy}px)"
          ><path d={icons.max.d} /></svg
        >
      </button>
    </div>
  {/if}
</div>

使い方

Example
<script lang="ts">
  import WindowSplitter from './WindowSplitter.svelte';

  function handlePositionChange(position: number, sizeInPx: number) {
    console.log('Position:', position, 'Size:', sizeInPx);
  }
</script>

<div class="layout">
  <div id="primary-pane" style="width: var(--splitter-position)">
    Primary Content
  </div>
  <WindowSplitter
    primaryPaneId="primary-pane"
    secondaryPaneId="secondary-pane"
    defaultPosition={50}
    min={20}
    max={80}
    step={5}
    aria-label="Resize panels"
    onpositionchange={handlePositionChange}
  />
  <div id="secondary-pane">
    Secondary Content
  </div>
</div>

API

プロパティ デフォルト 説明
primaryPaneId string required プライマリペインのID(aria-controls用)
secondaryPaneId string - セカンダリペインのID(任意)
defaultPosition number 50 初期位置(パーセンテージ 0-100)
orientation 'horizontal' | 'vertical' 'horizontal' スプリッターの向き
disabled boolean false 無効状態
readonly boolean false 読み取り専用状態

Custom Events

イベント Detail 説明
onpositionchange (position: number, sizeInPx: number) => void 位置変更時に呼び出し
oncollapsedchange (collapsed: boolean, previousPosition: number) => void 折り畳み状態変更時に呼び出し

テスト

テストは、ARIA構造、キーボードナビゲーション、フォーカス管理、ポインターインタラクションのAPG準拠を検証します。Window Splitterコンポーネントは2層のテスト戦略を使用します。

テスト戦略

ユニットテスト(Container API / Testing Library)

コンポーネントのHTML出力と基本的なインタラクションを検証します。これらのテストは正しいテンプレートレンダリングとARIA属性を確認します。

  • ARIA構造(role、aria-valuenow、aria-controls)
  • キーボードインタラクション(矢印キー、Home/End、Enter)
  • 折り畳み/展開機能
  • RTLサポート
  • 無効/読み取り専用状態

E2Eテスト(Playwright)

ポインターインタラクションを含む実際のブラウザ環境でコンポーネントの動作を検証します。

  • ポインタードラッグでリサイズ
  • Tabナビゲーション全体のフォーカス管理
  • フレームワーク間の一貫性
  • 視覚状態(CSSカスタムプロパティの更新)

テストカテゴリ

高優先度: ARIA構造

テスト 説明
role="separator" スプリッターにseparatorロールが設定されている
aria-valuenow プライマリペインのサイズ(パーセンテージ 0-100)
aria-valuemin/max 最小値と最大値が設定されている
aria-controls プライマリ(および任意のセカンダリ)ペインを参照
aria-orientation 垂直スプリッターの場合は"vertical"に設定
aria-disabled 無効時は"true"に設定

高優先度: キーボードインタラクション

テスト 説明
ArrowRight/Left 水平スプリッターを移動(増加/減少)
ArrowUp/Down 垂直スプリッターを移動(増加/減少)
Direction restriction 誤った方向のキーは効果なし
Shift+Arrow 大きなステップで移動
Home/End 最小/最大位置に移動
Enter (collapse) 0に折り畳み
Enter (expand) 以前の位置を復元
RTL support RTLモードでArrowLeft/Rightが反転

高優先度: フォーカス管理

テスト 説明
tabindex="0" スプリッターがフォーカス可能
tabindex="-1" 無効なスプリッターはフォーカス不可
readonly focusable 読み取り専用スプリッターはフォーカス可能だが操作不可
Focus after collapse フォーカスはスプリッターに残る

中優先度: ポインターインタラクション

テスト 説明
Drag to resize ドラッグ中に位置が更新される
Focus on click クリックでスプリッターにフォーカス
Disabled no response 無効なスプリッターはポインターを無視
Readonly no response 読み取り専用スプリッターはポインターを無視

中優先度: アクセシビリティ

テスト 説明
axe violations WCAG 2.1 AA違反なし
Collapsed state 折り畳み時に違反なし
Disabled state 無効時に違反なし

テストの実行

ユニットテスト

# Run all Window Splitter unit tests
npx vitest run src/patterns/windowsplitter/

# Run framework-specific tests
npm run test:react -- WindowSplitter.test.tsx
npm run test:vue -- WindowSplitter.test.vue.ts
npm run test:svelte -- WindowSplitter.test.svelte.ts
npm run test:astro

E2Eテスト

# Run all Window Splitter E2E tests
npm run test:e2e -- windowsplitter.spec.ts

# Run in UI mode
npm run test:e2e:ui -- windowsplitter.spec.ts

テストツール

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

WindowSplitter.test.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
import WindowSplitter from './WindowSplitter.svelte';
import WindowSplitterWithLabel from './WindowSplitterWithLabel.test.svelte';
import WindowSplitterWithDescribedby from './WindowSplitterWithDescribedby.test.svelte';
import WindowSplitterWithPanes from './WindowSplitterWithPanes.test.svelte';

describe('WindowSplitter (Svelte)', () => {
  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="separator"', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      expect(screen.getByRole('separator')).toBeInTheDocument();
    });

    it('has aria-valuenow set to current position (default: 50)', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('has aria-valuenow set to custom defaultPosition', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 30,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuenow', '30');
    });

    it('has aria-valuemin set (default: 10)', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemin', '10');
    });

    it('has aria-valuemax set (default: 90)', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemax', '90');
    });

    it('has custom aria-valuemin when provided', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', min: 5, 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemin', '5');
    });

    it('has custom aria-valuemax when provided', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', max: 95, 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemax', '95');
    });

    it('has aria-controls referencing primary pane', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'main-panel', 'aria-label': 'Resize panels' },
      });
      expect(screen.getByRole('separator')).toHaveAttribute('aria-controls', 'main-panel');
    });

    it('has aria-controls referencing both panes when secondaryPaneId provided', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          secondaryPaneId: 'secondary',
          'aria-label': 'Resize panels',
        },
      });
      expect(screen.getByRole('separator')).toHaveAttribute('aria-controls', 'primary secondary');
    });

    it('has aria-valuenow="0" when collapsed', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultCollapsed: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuenow', '0');
    });

    it('has aria-disabled="true" when disabled', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          disabled: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-disabled', 'true');
    });

    it('does not have aria-disabled when not disabled', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).not.toHaveAttribute('aria-disabled');
    });

    // Note: aria-readonly is not a valid attribute for role="separator"
    // Readonly behavior is enforced via JavaScript only

    it('clamps defaultPosition to min', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 5,
          min: 10,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuenow', '10');
    });

    it('clamps defaultPosition to max', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 95,
          max: 90,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuenow', '90');
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      expect(screen.getByRole('separator', { name: 'Resize panels' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(WindowSplitterWithLabel);
      expect(screen.getByRole('separator', { name: 'Panel Divider' })).toBeInTheDocument();
    });

    it('supports aria-describedby', () => {
      render(WindowSplitterWithDescribedby);
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-describedby', 'help');
    });
  });

  // 🔴 High Priority: Orientation
  describe('Orientation', () => {
    it('does not have aria-orientation for horizontal splitter (default)', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).not.toHaveAttribute('aria-orientation');
    });

    it('has aria-orientation="vertical" for vertical splitter', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          orientation: 'vertical',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-orientation', 'vertical');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Horizontal
  describe('Keyboard Interaction - Horizontal', () => {
    it('increases value by step on ArrowRight', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '55');
    });

    it('decreases value by step on ArrowLeft', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowLeft}');

      expect(separator).toHaveAttribute('aria-valuenow', '45');
    });

    it('increases value by largeStep on Shift+ArrowRight', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          largeStep: 10,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Shift>}{ArrowRight}{/Shift}');

      expect(separator).toHaveAttribute('aria-valuenow', '60');
    });

    it('decreases value by largeStep on Shift+ArrowLeft', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          largeStep: 10,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Shift>}{ArrowLeft}{/Shift}');

      expect(separator).toHaveAttribute('aria-valuenow', '40');
    });

    it('ignores ArrowUp on horizontal splitter', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowUp}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('ignores ArrowDown on horizontal splitter', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowDown}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Vertical
  describe('Keyboard Interaction - Vertical', () => {
    it('moves separator up on ArrowUp', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          orientation: 'vertical',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowUp}');

      expect(separator).toHaveAttribute('aria-valuenow', '45');
    });

    it('moves separator down on ArrowDown', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          orientation: 'vertical',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowDown}');

      expect(separator).toHaveAttribute('aria-valuenow', '55');
    });

    it('ignores ArrowLeft on vertical splitter', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          orientation: 'vertical',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowLeft}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('ignores ArrowRight on vertical splitter', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          orientation: 'vertical',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Collapse/Expand
  describe('Keyboard Interaction - Collapse/Expand', () => {
    it('collapses on Enter (aria-valuenow becomes 0)', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}');

      expect(separator).toHaveAttribute('aria-valuenow', '0');
    });

    it('restores previous value on Enter after collapse', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}'); // Collapse
      await user.keyboard('{Enter}'); // Expand

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('expands to expandedPosition when initially collapsed', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultCollapsed: true,
          expandedPosition: 30,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}'); // Expand

      expect(separator).toHaveAttribute('aria-valuenow', '30');
    });

    it('does not collapse when collapsible is false', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          collapsible: false,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('remembers position across multiple collapse/expand cycles', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}'); // 55
      await user.keyboard('{Enter}'); // Collapse → 0
      expect(separator).toHaveAttribute('aria-valuenow', '0');

      await user.keyboard('{Enter}'); // Expand → 55
      expect(separator).toHaveAttribute('aria-valuenow', '55');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Home/End
  describe('Keyboard Interaction - Home/End', () => {
    it('sets min value on Home', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          min: 10,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Home}');

      expect(separator).toHaveAttribute('aria-valuenow', '10');
    });

    it('sets max value on End', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          max: 90,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{End}');

      expect(separator).toHaveAttribute('aria-valuenow', '90');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - RTL
  describe('Keyboard Interaction - RTL', () => {
    it('ArrowLeft increases value in RTL mode (horizontal)', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          dir: 'rtl',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowLeft}');

      expect(separator).toHaveAttribute('aria-valuenow', '55');
    });

    it('ArrowRight decreases value in RTL mode (horizontal)', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          dir: 'rtl',
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '45');
    });
  });

  // 🔴 High Priority: Keyboard Interaction - Disabled/Readonly
  describe('Keyboard Interaction - Disabled/Readonly', () => {
    it('does not change value when disabled', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          disabled: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      separator.focus();
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('does not change value when readonly', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          readonly: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('does not collapse when disabled', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          disabled: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      separator.focus();
      await user.keyboard('{Enter}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('does not collapse when readonly', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          readonly: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}');

      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" on separator', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('tabindex', '0');
    });

    it('has tabindex="-1" when disabled', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          disabled: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('tabindex', '-1');
    });

    it('is focusable when readonly', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          readonly: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('tabindex', '0');
    });

    it('can be focused via click', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);

      expect(document.activeElement).toBe(separator);
    });
  });

  // 🟡 Medium Priority: Pointer Interaction
  describe('Pointer Interaction', () => {
    it('focuses separator on pointer down', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

      await user.pointer({ target: separator, keys: '[MouseLeft>]' });

      expect(document.activeElement).toBe(separator);
    });

    it('does not start drag when disabled', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          disabled: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      // tabindex should be -1 when disabled
      expect(separator).toHaveAttribute('tabindex', '-1');
    });

    it('does not start drag when readonly', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          readonly: true,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      // readonly should still be focusable (tabindex="0")
      expect(separator).toHaveAttribute('tabindex', '0');
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(WindowSplitterWithPanes, {
        props: { 'aria-label': 'Resize panels' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when collapsed', async () => {
      const { container } = render(WindowSplitterWithPanes, {
        props: { defaultCollapsed: true, 'aria-label': 'Resize panels' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(WindowSplitterWithPanes, {
        props: { disabled: true, 'aria-label': 'Resize panels' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations for vertical splitter', async () => {
      const { container } = render(WindowSplitterWithPanes, {
        props: { orientation: 'vertical', 'aria-label': 'Resize panels' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-labelledby', async () => {
      const { container } = render(WindowSplitterWithLabel);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Callbacks
  describe('Callbacks', () => {
    it('calls onpositionchange on keyboard interaction', async () => {
      const user = userEvent.setup();
      let capturedPosition: number | undefined;
      let capturedSizeInPx: number | undefined;

      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          step: 5,
          'aria-label': 'Resize panels',
          onpositionchange: (pos: number, size: number) => {
            capturedPosition = pos;
            capturedSizeInPx = size;
          },
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(capturedPosition).toBe(55);
      expect(typeof capturedSizeInPx).toBe('number');
    });

    it('calls oncollapsedchange on collapse', async () => {
      const user = userEvent.setup();
      let capturedCollapsed: boolean | undefined;
      let capturedPreviousPosition: number | undefined;

      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
          oncollapsedchange: (collapsed: boolean, prevPos: number) => {
            capturedCollapsed = collapsed;
            capturedPreviousPosition = prevPos;
          },
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}');

      expect(capturedCollapsed).toBe(true);
      expect(capturedPreviousPosition).toBe(50);
    });

    it('calls oncollapsedchange on expand', async () => {
      const user = userEvent.setup();
      let capturedCollapsed: boolean | undefined;

      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultCollapsed: true,
          'aria-label': 'Resize panels',
          oncollapsedchange: (collapsed: boolean) => {
            capturedCollapsed = collapsed;
          },
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{Enter}');

      expect(capturedCollapsed).toBe(false);
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('does not exceed max on ArrowRight', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 88,
          max: 90,
          step: 5,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '90');
    });

    it('does not go below min on ArrowLeft', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 12,
          min: 10,
          step: 5,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowLeft}');

      expect(separator).toHaveAttribute('aria-valuenow', '10');
    });

    it('handles min=0 max=100 range', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          min: 0,
          max: 100,
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemin', '0');
      expect(separator).toHaveAttribute('aria-valuemax', '100');
      expect(separator).toHaveAttribute('aria-valuenow', '50');
    });

    it('handles custom min/max range', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          min: 20,
          max: 80,
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('aria-valuemin', '20');
      expect(separator).toHaveAttribute('aria-valuemax', '80');
    });

    it('prevents default on handled keyboard events', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          defaultPosition: 50,
          'aria-label': 'Resize panels',
        },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);
      await user.keyboard('{ArrowRight}');

      expect(separator).toHaveAttribute('aria-valuenow', '55');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies class to container', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          'aria-label': 'Resize panels',
          class: 'custom-splitter',
        },
      });
      const container = screen.getByRole('separator').closest('.apg-window-splitter');
      expect(container).toHaveClass('custom-splitter');
    });

    it('sets id attribute on separator element', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          'aria-label': 'Resize panels',
          id: 'my-splitter',
        },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('id', 'my-splitter');
    });

    it('passes through data-* attributes', () => {
      render(WindowSplitter, {
        props: {
          primaryPaneId: 'primary',
          'aria-label': 'Resize panels',
          'data-testid': 'custom-splitter',
        },
      });
      expect(screen.getByTestId('custom-splitter')).toBeInTheDocument();
    });
  });
});

リソース