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.vue
<template>
  <div
    ref="containerRef"
    :class="[
      'apg-window-splitter',
      isVertical && 'apg-window-splitter--vertical',
      disabled && 'apg-window-splitter--disabled',
      $attrs.class,
    ]"
    :style="{ '--splitter-position': `${position}%` }"
  >
    <div
      ref="splitterRef"
      role="separator"
      :id="$attrs.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="$attrs['aria-label']"
      :aria-labelledby="$attrs['aria-labelledby']"
      :aria-describedby="$attrs['aria-describedby']"
      :data-testid="$attrs['data-testid']"
      class="apg-window-splitter__separator"
      @keydown="handleKeyDown"
      @pointerdown="handlePointerDown"
      @pointermove="handlePointerMove"
      @pointerup="handlePointerUp"
      @mouseenter="handleSeparatorMouseEnter"
      @mouseleave="handleSeparatorMouseLeave"
      @mousemove="handleSeparatorMouseMove"
      @focus="handleSeparatorFocus"
      @blur="handleSeparatorBlur"
    />
    <div
      v-if="!disabled && !readonly"
      ref="popupRef"
      role="group"
      :aria-label="`Adjust ${splitterLabel}`"
      :class="[
        'apg-window-splitter__popup',
        popupState !== 'hidden' && 'apg-window-splitter__popup--visible',
      ]"
      :style="
        popupPos
          ? {
              left: `${popupPos.x}px`,
              top: `${popupPos.y}px`,
              flexDirection: isVertical ? 'column' : 'row',
            }
          : undefined
      "
      @mouseenter="handlePopupMouseEnter"
      @mouseleave="handlePopupMouseLeave"
      @keydown="handlePopupKeyDown"
    >
      <button
        type="button"
        class="apg-window-splitter__popup-button"
        :tabindex="-1"
        :aria-label="`Collapse ${splitterLabel}`"
        :aria-disabled="isAtMin"
        @click="!isAtMin && handlePopupButtonClick(props.min - positionLocal)"
      >
        <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"
        @click="!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"
        @click="!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"
        @click="!isAtMax && handlePopupButtonClick(props.max - positionLocal)"
      >
        <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>
  </div>
</template>

<script setup lang="ts">
import { computed, getCurrentInstance, onUnmounted, ref, watch } from 'vue';

defineOptions({
  inheritAttrs: false,
});

export 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;
}

const props = withDefaults(defineProps<WindowSplitterProps>(), {
  secondaryPaneId: undefined,
  defaultPosition: 50,
  defaultCollapsed: false,
  expandedPosition: undefined,
  min: 10,
  max: 90,
  step: 5,
  largeStep: 10,
  orientation: 'horizontal',
  dir: undefined,
  collapsible: true,
  disabled: false,
  readonly: false,
});

const emit = defineEmits<{
  positionChange: [position: number, sizeInPx: number];
  collapsedChange: [collapsed: boolean, previousPosition: number];
}>();

const instance = getCurrentInstance();

// 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
const clamp = (value: number, minVal: number, maxVal: number): number => {
  return Math.min(maxVal, Math.max(minVal, value));
};

// Refs
const splitterRef = ref<HTMLDivElement | null>(null);
const containerRef = ref<HTMLDivElement | null>(null);
const popupRef = ref<HTMLDivElement | null>(null);
const previousPosition = ref<number | null>(
  props.defaultCollapsed ? null : clamp(props.defaultPosition, props.min, props.max)
);

// State
const initialPosition = props.defaultCollapsed
  ? 0
  : clamp(props.defaultPosition, props.min, props.max);
const position = ref(initialPosition);
const collapsed = ref(props.defaultCollapsed);
const popupState = ref<'hidden' | 'showing' | 'active'>('hidden');
const popupPos = ref<{ x: number; y: number } | null>(null);

// Non-reactive variables for immediate tracking (avoid reactivity overhead)
let positionLocal = initialPosition;
let isDragging = false;
let isMouseOverPopup = false;
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
let dismissTimer: ReturnType<typeof setTimeout> | null = null;
let activeSettleTimer: ReturnType<typeof setTimeout> | null = null;
let pointerStart: { x: number; y: number } | null = null;
let hoverPos: { x: number; y: number } | null = null;

// Computed
const isVertical = computed(() => props.orientation === 'vertical');
const isHorizontal = computed(() => props.orientation === 'horizontal');

const isRTL = computed(() => {
  if (props.dir === 'rtl') return true;
  if (props.dir === 'ltr') return false;
  if (typeof document !== 'undefined') {
    return document.dir === 'rtl';
  }
  return false;
});

const ariaControls = computed(() => {
  if (props.secondaryPaneId) {
    return `${props.primaryPaneId} ${props.secondaryPaneId}`;
  }
  return props.primaryPaneId;
});

const splitterLabel = computed(() => {
  const label = instance?.attrs['aria-label'];
  return typeof label === 'string' ? label : '';
});

const isAtMin = computed(() => position.value <= props.min);
const isAtMax = computed(() => position.value >= props.max);

const icons = computed(() =>
  isVertical.value
    ? {
        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 management
const clearAllTimers = () => {
  if (hoverTimer) clearTimeout(hoverTimer);
  if (dismissTimer) clearTimeout(dismissTimer);
  if (activeSettleTimer) clearTimeout(activeSettleTimer);
  hoverTimer = null;
  dismissTimer = null;
  activeSettleTimer = null;
};

onUnmounted(() => {
  clearAllTimers();
});

watch(popupState, (state, _, onCleanup) => {
  if (state !== 'active') return;
  const handleOutsidePointerDown = (e: PointerEvent) => {
    if (popupRef.value && !popupRef.value.contains(e.target as Node)) {
      hidePopup();
    }
  };
  document.addEventListener('pointerdown', handleOutsidePointerDown);
  onCleanup(() => document.removeEventListener('pointerdown', handleOutsidePointerDown));
});

// Popup position calculation
const calcPopupPosition = (clientX: number, clientY: number) => {
  if (!splitterRef.value) return null;
  const popupEl = popupRef.value;
  const popupWidth = popupEl?.offsetWidth || (isVertical.value ? 34 : 120);
  const popupHeight = popupEl?.offsetHeight || (isVertical.value ? 120 : 34);
  const vw = window.innerWidth;
  const vh = window.innerHeight;

  let x: number;
  let y: number;

  if (isHorizontal.value) {
    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 };
};

// Popup show/hide
const showPopup = (clientX: number, clientY: number) => {
  if (props.disabled || props.readonly) return;
  if (dismissTimer) {
    clearTimeout(dismissTimer);
    dismissTimer = null;
  }
  const pos = calcPopupPosition(clientX, clientY);
  if (pos) {
    popupPos.value = pos;
    popupState.value = 'showing';
  }
};

const hidePopup = () => {
  clearAllTimers();
  popupState.value = 'hidden';
  popupPos.value = null;
};

const scheduleDismiss = () => {
  if (dismissTimer) clearTimeout(dismissTimer);
  dismissTimer = setTimeout(() => {
    const splitter = splitterRef.value;
    const popup = popupRef.value;
    const hasFocusInside =
      popup?.contains(document.activeElement) || splitter === document.activeElement;
    if (!hasFocusInside) {
      hidePopup();
    }
  }, DISMISS_DELAY);
};

// Update position and emit
const updatePosition = (newPosition: number) => {
  const clampedPosition = clamp(newPosition, props.min, props.max);
  if (clampedPosition !== positionLocal) {
    positionLocal = clampedPosition;
    position.value = clampedPosition;

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

    emit('positionChange', clampedPosition, sizeInPx);
  }
};

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

// Handle collapse/expand
const handleToggleCollapse = () => {
  if (!props.collapsible || props.disabled || props.readonly) return;

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

    emit('collapsedChange', false, position.value);
    collapsed.value = false;
    positionLocal = clampedRestore;
    position.value = clampedRestore;

    const container = containerRef.value;
    const sizeInPx = container
      ? (clampedRestore / 100) *
        (isHorizontal.value ? container.offsetWidth : container.offsetHeight)
      : 0;
    emit('positionChange', clampedRestore, sizeInPx);
  } else {
    // Collapse: save current position, set to 0
    previousPosition.value = position.value;
    emit('collapsedChange', true, position.value);
    collapsed.value = true;
    positionLocal = 0;
    position.value = 0;
    emit('positionChange', 0, 0);
  }
};

// Keyboard handler
const handleKeyDown = (event: KeyboardEvent) => {
  if (props.disabled || props.readonly) return;

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

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

  let delta = 0;
  let handled = false;

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

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

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

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

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

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

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

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

// Pointer handlers
const handlePointerDown = (event: PointerEvent) => {
  if (props.disabled || props.readonly) return;

  event.preventDefault();
  const splitter = splitterRef.value;
  if (!splitter) return;

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

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

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

  splitter.focus();
};

const 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.value !== 'hidden') hidePopup();
    } else {
      return;
    }
  }

  const container = containerRef.value;
  if (!container) return;

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

  let percent: number;
  if (isHorizontal.value) {
    const x = event.clientX - rect.left;
    percent = (x / rect.width) * 100;
  } else {
    const y = event.clientY - rect.top;
    // For vertical, y position corresponds to primary pane height
    percent = (y / rect.height) * 100;
  }

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

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

  updatePosition(percent);
};

const handlePointerUp = (event: PointerEvent) => {
  const splitter = splitterRef.value;
  if (splitter && typeof splitter.releasePointerCapture === 'function') {
    try {
      splitter.releasePointerCapture(event.pointerId);
    } catch {
      // Ignore
    }
  }

  const start = pointerStart;
  if (start && !isDragging) {
    showPopup(start.x, start.y);
  }

  isDragging = false;
  pointerStart = null;
};

// Separator mouse handlers for hover popup
const handleSeparatorMouseEnter = (event: MouseEvent) => {
  if (props.disabled || props.readonly || isDragging) return;
  hoverPos = { x: event.clientX, y: event.clientY };
  if (popupState.value === '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;
  }
};

const handleSeparatorMouseLeave = () => {
  if (hoverTimer) {
    clearTimeout(hoverTimer);
    hoverTimer = null;
  }
  if (popupState.value !== 'hidden') scheduleDismiss();
};

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

// Separator focus/blur handlers
const handleSeparatorFocus = () => {
  if (props.disabled || props.readonly) return;
  if (popupState.value === 'hidden') {
    const splitter = splitterRef.value;
    if (splitter) {
      const rect = splitter.getBoundingClientRect();
      showPopup(rect.left + rect.width / 2, rect.top + rect.height / 2);
    }
  }
  if (dismissTimer) {
    clearTimeout(dismissTimer);
    dismissTimer = null;
  }
};

const handleSeparatorBlur = () => {
  if (popupState.value !== 'hidden') {
    setTimeout(() => {
      const popup = popupRef.value;
      const splitter = splitterRef.value;
      if (!popup?.contains(document.activeElement) && splitter !== document.activeElement) {
        scheduleDismiss();
      }
    }, 0);
  }
};

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

const handlePopupMouseLeave = () => {
  isMouseOverPopup = false;
  if (popupState.value !== 'hidden') scheduleDismiss();
};

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

使い方

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

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

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

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 説明
@positionchange (position: number, sizeInPx: number) 位置変更時に発火
@collapsedchange (collapsed: boolean, previousPosition: number) 折り畳み状態変更時に発火

テスト

テストは、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.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
import WindowSplitter from './WindowSplitter.vue';

// Helper to render with panes for aria-controls validation
const renderWithPanes = (
  props: Record<string, unknown> = {},
  attrs: Record<string, unknown> = {}
) => {
  return render({
    components: { WindowSplitter },
    template: `
      <div>
        <div id="primary">Primary Pane</div>
        <div id="secondary">Secondary Pane</div>
        <WindowSplitter v-bind="allProps" />
      </div>
    `,
    data() {
      return {
        allProps: {
          primaryPaneId: 'primary',
          ...props,
          ...attrs,
        },
      };
    },
  });
};

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

    it('has aria-valuenow set to current position (default: 50)', () => {
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary' },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { 'aria-label': 'Resize panels' },
      });
      expect(screen.getByRole('separator', { name: 'Resize panels' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render({
        components: { WindowSplitter },
        template: `
          <div>
            <span id="splitter-label">Panel Divider</span>
            <WindowSplitter primaryPaneId="primary" aria-labelledby="splitter-label" />
          </div>
        `,
      });
      expect(screen.getByRole('separator', { name: 'Panel Divider' })).toBeInTheDocument();
    });

    it('supports aria-describedby', () => {
      render({
        components: { WindowSplitter },
        template: `
          <div>
            <WindowSplitter primaryPaneId="primary" aria-label="Resize" aria-describedby="help" />
            <p id="help">Press Enter to collapse</p>
          </div>
        `,
      });
      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' },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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,
        },
        attrs: { '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,
        },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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',
        },
        attrs: { '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',
        },
        attrs: { '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',
        },
        attrs: { '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',
        },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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,
        },
        attrs: { '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,
        },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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',
        },
        attrs: { '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',
        },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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' },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');
      expect(separator).toHaveAttribute('tabindex', '0');
    });

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

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

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

    it('maintains focus after expand', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', defaultCollapsed: true },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

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

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

    it('can be focused via click', async () => {
      const user = userEvent.setup();
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary' },
        attrs: { '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' },
        attrs: { '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', async () => {
      // When disabled, the handler returns early without calling setPointerCapture
      // The key test is that keyboard operations are blocked, not focus behavior
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', disabled: true },
        attrs: { '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', async () => {
      // When readonly, the handler returns early without calling setPointerCapture
      // readonly is focusable but not operable - keyboard tests verify this behavior
      render(WindowSplitter, {
        props: { primaryPaneId: 'primary', readonly: true },
        attrs: { '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 } = renderWithPanes({}, { '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 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 for vertical splitter', async () => {
      const { container } = renderWithPanes(
        { 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({
        components: { WindowSplitter },
        template: `
          <div>
            <span id="splitter-label">Panel Divider</span>
            <div id="primary">Primary Pane</div>
            <WindowSplitter primaryPaneId="primary" aria-labelledby="splitter-label" />
          </div>
        `,
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Events
  describe('Events', () => {
    it('emits positionChange on keyboard interaction', async () => {
      const user = userEvent.setup();
      const { emitted } = render(WindowSplitter, {
        props: { primaryPaneId: 'primary', defaultPosition: 50, step: 5 },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

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

      expect(emitted('positionChange')).toBeTruthy();
      const [position] = emitted('positionChange')[0] as [number, number];
      expect(position).toBe(55);
    });

    it('emits collapsedChange on collapse', async () => {
      const user = userEvent.setup();
      const { emitted } = render(WindowSplitter, {
        props: { primaryPaneId: 'primary', defaultPosition: 50 },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

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

      expect(emitted('collapsedChange')).toBeTruthy();
      const [collapsed, previousPosition] = emitted('collapsedChange')[0] as [boolean, number];
      expect(collapsed).toBe(true);
      expect(previousPosition).toBe(50);
    });

    it('emits collapsedChange on expand', async () => {
      const user = userEvent.setup();
      const { emitted } = render(WindowSplitter, {
        props: { primaryPaneId: 'primary', defaultCollapsed: true },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

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

      expect(emitted('collapsedChange')).toBeTruthy();
      const [collapsed] = emitted('collapsedChange')[0] as [boolean, number];
      expect(collapsed).toBe(false);
    });

    it('emits positionChange with sizeInPx parameter', async () => {
      const user = userEvent.setup();
      const { emitted } = render(WindowSplitter, {
        props: { primaryPaneId: 'primary', defaultPosition: 50, step: 5 },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

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

      expect(emitted('positionChange')).toBeTruthy();
      const [position, sizeInPx] = emitted('positionChange')[0] as [number, number];
      expect(position).toBe(55);
      expect(typeof sizeInPx).toBe('number');
    });

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

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

      expect(emitted('positionChange')).toBeFalsy();
    });
  });

  // 🟡 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 },
        attrs: { '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 },
        attrs: { '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 },
        attrs: { '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,
        },
        attrs: { '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 },
        attrs: { 'aria-label': 'Resize panels' },
      });
      const separator = screen.getByRole('separator');

      await user.click(separator);

      // The fact that ArrowRight changes the value means preventDefault worked
      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' },
        attrs: { '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' },
        attrs: { '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' },
        attrs: {
          'aria-label': 'Resize panels',
          'data-testid': 'custom-splitter',
        },
      });
      expect(screen.getByTestId('custom-splitter')).toBeInTheDocument();
    });
  });
});

リソース