APG Patterns
日本語 GitHub
日本語 GitHub

Window Splitter

A movable separator between two panes that allows users to resize the relative size of each pane. Used in IDEs, file browsers, and resizable layouts.

🤖 AI Implementation Guide

Demo

Use Arrow keys to move the splitter. Press Enter to collapse/expand. Shift+Arrow moves by a larger step. Home/End moves to min/max position.

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

Open demo only →

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
separator Splitter element Focusable separator that controls pane size

WAI-ARIA separator role (opens in new tab)

WAI-ARIA Properties

Attribute Target Values Required Description
aria-valuenow separator 0-100 Yes Primary pane size as percentage
aria-valuemin separator number Yes Minimum value (default: 10)
aria-valuemax separator number Yes Maximum value (default: 90)
aria-controls separator ID reference(s) Yes Primary pane ID (+ secondary pane ID optional)
aria-label separator string Conditional Accessible name (required if no aria-labelledby)
aria-labelledby separator ID reference Conditional Reference to visible label element
aria-orientation separator "horizontal" | "vertical" No Default: horizontal (left-right split)
aria-disabled separator true | false No Disabled state

Note: aria-readonly is NOT valid for role="separator". Readonly behavior must be enforced via JavaScript only.

WAI-ARIA States

aria-valuenow

Current position of the splitter as a percentage (0-100).

Target separator element
Values 0-100 (0 = collapsed, 50 = half, 100 = fully expanded)
Required Yes
Change Trigger Arrow keys, Home/End, Enter (collapse/expand), pointer drag
Reference aria-valuenow (opens in new tab)

Keyboard Support

Key Action
Arrow Right / Arrow Left Move horizontal splitter (increase/decrease)
Arrow Up / Arrow Down Move vertical splitter (increase/decrease)
Shift + Arrow Move by large step (default: 10%)
Home Move to minimum position
End Move to maximum position
Enter Toggle collapse/expand primary pane

Note: Arrow keys are direction-restricted based on orientation. Horizontal splitters only respond to Left/Right, vertical splitters only to Up/Down. In RTL mode, ArrowLeft increases and ArrowRight decreases for horizontal splitters.

Source Code

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"
    />
  </div>
</template>

<script setup lang="ts">
import { computed, ref } 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];
}>();

// 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 isDragging = ref(false);
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);

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

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

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

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

// 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;
    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;
    position.value = 0;
    emit('positionChange', 0, 0);
  }
};

// Keyboard handler
const handleKeyDown = (event: KeyboardEvent) => {
  if (props.disabled || props.readonly) 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(position.value + delta);
    }
  }
};

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

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

  if (typeof splitter.setPointerCapture === 'function') {
    splitter.setPointerCapture(event.pointerId);
  }
  isDragging.value = true;
  splitter.focus();
};

const handlePointerMove = (event: PointerEvent) => {
  if (!isDragging.value) return;

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

  // Use demo container for stable measurement if available
  const demoContainer = container.closest(
    '.apg-window-splitter-demo-container'
  ) as HTMLElement | 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
    }
  }
  isDragging.value = false;
};
</script>

Usage

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

Props

Prop Type Default Description
primaryPaneId string required ID of primary pane
secondaryPaneId string - ID of secondary pane
defaultPosition number 50 Initial position (%)
orientation 'horizontal' | 'vertical' 'horizontal' Splitter orientation
disabled boolean false Disabled state
readonly boolean false Readonly state

Events

Event Payload Description
@positionchange (position: number, sizeInPx: number) Emitted when position changes
@collapsedchange (collapsed: boolean, previousPosition: number) Emitted when collapsed state changes

Testing

Tests verify APG compliance across ARIA structure, keyboard navigation, focus management, and pointer interaction. The Window Splitter component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Container API / Testing Library)

Verify the component's HTML output and basic interactions. These tests ensure correct template rendering and ARIA attributes.

  • ARIA structure (role, aria-valuenow, aria-controls)
  • Keyboard interaction (Arrow keys, Home/End, Enter)
  • Collapse/expand functionality
  • RTL support
  • Disabled/readonly states

E2E Tests (Playwright)

Verify component behavior in a real browser environment including pointer interactions.

  • Pointer drag to resize
  • Focus management across Tab navigation
  • Cross-framework consistency
  • Visual state (CSS custom property updates)

Test Categories

High Priority: ARIA Structure

Test Description
role="separator" Splitter has separator role
aria-valuenow Primary pane size as percentage (0-100)
aria-valuemin/max Minimum and maximum values set
aria-controls References primary (and optional secondary) pane
aria-orientation Set to "vertical" for vertical splitter
aria-disabled Set to "true" when disabled

High Priority: Keyboard Interaction

Test Description
ArrowRight/Left Moves horizontal splitter (increases/decreases)
ArrowUp/Down Moves vertical splitter (increases/decreases)
Direction restriction Wrong-direction keys have no effect
Shift+Arrow Moves by large step
Home/End Moves to min/max position
Enter (collapse) Collapses to 0
Enter (expand) Restores previous position
RTL support ArrowLeft/Right reversed in RTL mode

High Priority: Focus Management

Test Description
tabindex="0" Splitter is focusable
tabindex="-1" Disabled splitter is not focusable
readonly focusable Readonly splitter is focusable but not operable
Focus after collapse Focus remains on splitter

Medium Priority: Pointer Interaction

Test Description
Drag to resize Position updates during drag
Focus on click Clicking focuses the splitter
Disabled no response Disabled splitter ignores pointer
Readonly no response Readonly splitter ignores pointer

Medium Priority: Accessibility

Test Description
axe violations No WCAG 2.1 AA violations
Collapsed state No violations when collapsed
Disabled state No violations when disabled

Running Tests

Unit Tests

# Run all Window Splitter unit tests
npx vitest run src/patterns/window-splitter/

# 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 Tests

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

# Run in UI mode
npm run test:e2e:ui -- window-splitter.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('increases value by step 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', '55');
    });

    it('decreases value by step 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', '45');
    });

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

Resources