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.tsx
import { clsx } from 'clsx';
import type { CSSProperties } from 'react';
import { useCallback, useRef, useState } from 'react';

// CSS custom properties type for splitter position
interface SplitterStyle extends CSSProperties {
  '--splitter-position': string;
}

// Label: one of these required (exclusive)
type LabelProps =
  | { 'aria-label': string; 'aria-labelledby'?: never }
  | { 'aria-label'?: never; 'aria-labelledby': string };

type WindowSplitterBaseProps = {
  /** Primary pane ID (required for aria-controls) */
  primaryPaneId: string;

  /** Secondary pane ID (optional, added to aria-controls) */
  secondaryPaneId?: string;

  /** Initial position as % (0-100, default: 50) */
  defaultPosition?: number;

  /** Initial collapsed state (default: false) */
  defaultCollapsed?: boolean;

  /** Position when expanding from initial collapse */
  expandedPosition?: number;

  /** Minimum position as % (default: 10) */
  min?: number;

  /** Maximum position as % (default: 90) */
  max?: number;

  /** Keyboard step as % (default: 5) */
  step?: number;

  /** Shift+Arrow step as % (default: 10) */
  largeStep?: number;

  /** Splitter orientation (default: horizontal = left-right split) */
  orientation?: 'horizontal' | 'vertical';

  /** Text direction for RTL support */
  dir?: 'ltr' | 'rtl';

  /** Whether pane can be collapsed (default: true) */
  collapsible?: boolean;

  /** Disabled state (not focusable, not operable) */
  disabled?: boolean;

  /** Readonly state (focusable but not operable) */
  readonly?: boolean;

  /** Callback when position changes */
  onPositionChange?: (position: number, sizeInPx: number) => void;

  /** Callback when collapsed state changes */
  onCollapsedChange?: (collapsed: boolean, previousPosition: number) => void;

  /** Reference to help text */
  'aria-describedby'?: string;

  /** Test id for testing */
  'data-testid'?: string;

  className?: string;
  id?: string;
};

export type WindowSplitterProps = WindowSplitterBaseProps & LabelProps;

// Clamp value to min/max range
const clamp = (value: number, min: number, max: number): number => {
  return Math.min(max, Math.max(min, value));
};

export const WindowSplitter: React.FC<WindowSplitterProps> = ({
  primaryPaneId,
  secondaryPaneId,
  defaultPosition = 50,
  defaultCollapsed = false,
  expandedPosition,
  min = 10,
  max = 90,
  step = 5,
  largeStep = 10,
  orientation = 'horizontal',
  dir,
  collapsible = true,
  disabled = false,
  readonly = false,
  onPositionChange,
  onCollapsedChange,
  'aria-describedby': ariaDescribedby,
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  'data-testid': dataTestid,
  className,
  id,
}) => {
  // Calculate initial position: clamp to valid range, or 0 if collapsed
  const initialPosition = defaultCollapsed ? 0 : clamp(defaultPosition, min, max);

  const [position, setPosition] = useState(initialPosition);
  const [collapsed, setCollapsed] = useState(defaultCollapsed);

  const splitterRef = useRef<HTMLDivElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const previousPositionRef = useRef<number | null>(defaultCollapsed ? null : initialPosition);

  const isHorizontal = orientation === 'horizontal';
  const isVertical = orientation === 'vertical';

  // Determine RTL mode
  const isRTL =
    dir === 'rtl' ||
    (dir === undefined && typeof document !== 'undefined' && document.dir === 'rtl');

  // Update position and call callback
  const updatePosition = useCallback(
    (newPosition: number) => {
      const clampedPosition = clamp(newPosition, min, max);
      if (clampedPosition !== position) {
        setPosition(clampedPosition);

        // Calculate size in px (approximation, actual calculation needs container)
        const container = containerRef.current;
        const sizeInPx = container
          ? (clampedPosition / 100) *
            (isHorizontal ? container.offsetWidth : container.offsetHeight)
          : 0;

        onPositionChange?.(clampedPosition, sizeInPx);
      }
    },
    [position, min, max, isHorizontal, onPositionChange]
  );

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

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

      onCollapsedChange?.(false, position);
      setCollapsed(false);
      setPosition(clampedRestore);

      const container = containerRef.current;
      const sizeInPx = container
        ? (clampedRestore / 100) * (isHorizontal ? container.offsetWidth : container.offsetHeight)
        : 0;
      onPositionChange?.(clampedRestore, sizeInPx);
    } else {
      // Collapse: save current position, set to 0
      previousPositionRef.current = position;
      onCollapsedChange?.(true, position);
      setCollapsed(true);
      setPosition(0);
      onPositionChange?.(0, 0);
    }
  }, [
    collapsed,
    collapsible,
    disabled,
    readonly,
    position,
    expandedPosition,
    defaultPosition,
    min,
    max,
    isHorizontal,
    onCollapsedChange,
    onPositionChange,
  ]);

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

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

      let delta = 0;
      let handled = false;

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

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

        // Vertical splitter: ArrowUp/Down only
        case 'ArrowUp':
          if (!isVertical) break;
          delta = currentStep;
          handled = true;
          break;

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

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

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

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

      if (handled) {
        event.preventDefault();
        if (delta !== 0) {
          updatePosition(position + delta);
        }
      }
    },
    [
      disabled,
      readonly,
      isHorizontal,
      isVertical,
      isRTL,
      step,
      largeStep,
      position,
      min,
      max,
      handleToggleCollapse,
      updatePosition,
    ]
  );

  // Pointer handlers
  const isDraggingRef = useRef(false);

  const handlePointerDown = useCallback(
    (event: React.PointerEvent) => {
      if (disabled || readonly) return;

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

      if (typeof splitter.setPointerCapture === 'function') {
        splitter.setPointerCapture(event.pointerId);
      }
      isDraggingRef.current = true;
      splitter.focus();
    },
    [disabled, readonly]
  );

  const handlePointerMove = useCallback(
    (event: React.PointerEvent) => {
      if (!isDraggingRef.current) return;

      const container = containerRef.current;
      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) {
        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, min, max);

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

      updatePosition(percent);
    },
    [isHorizontal, min, max, updatePosition]
  );

  const handlePointerUp = useCallback((event: React.PointerEvent) => {
    const splitter = splitterRef.current;
    if (splitter && typeof splitter.releasePointerCapture === 'function') {
      try {
        splitter.releasePointerCapture(event.pointerId);
      } catch {
        // Ignore if pointer capture was not set
      }
    }
    isDraggingRef.current = false;
  }, []);

  // Compute aria-controls
  const ariaControls = secondaryPaneId ? `${primaryPaneId} ${secondaryPaneId}` : primaryPaneId;

  return (
    <div
      ref={containerRef}
      className={clsx(
        'apg-window-splitter',
        isVertical && 'apg-window-splitter--vertical',
        disabled && 'apg-window-splitter--disabled',
        className
      )}
      style={{ '--splitter-position': `${position}%` } satisfies SplitterStyle}
    >
      <div
        ref={splitterRef}
        role="separator"
        id={id}
        tabIndex={disabled ? -1 : 0}
        aria-valuenow={position}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-controls={ariaControls}
        aria-orientation={isVertical ? 'vertical' : undefined}
        aria-disabled={disabled ? true : undefined}
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledby}
        aria-describedby={ariaDescribedby}
        data-testid={dataTestid}
        className="apg-window-splitter__separator"
        onKeyDown={handleKeyDown}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      />
    </div>
  );
};

Usage

Example
import { WindowSplitter } from './WindowSplitter';

function App() {
  return (
    <div className="layout">
      <div id="primary-pane" style={{ width: 'var(--splitter-position)' }}>
        Primary Content
      </div>
      <WindowSplitter
        primaryPaneId="primary-pane"
        secondaryPaneId="secondary-pane"
        defaultPosition={50}
        min={20}
        max={80}
        step={5}
        aria-label="Resize panels"
        onPositionChange={(position, sizeInPx) => {
          console.log('Position:', position, 'Size:', sizeInPx);
        }}
      />
      <div id="secondary-pane">
        Secondary Content
      </div>
    </div>
  );
}

API

WindowSplitter Props

Prop Type Default Description
primaryPaneId string required ID of primary pane (for aria-controls)
secondaryPaneId string - ID of secondary pane (optional)
defaultPosition number 50 Initial position as percentage (0-100)
defaultCollapsed boolean false Start in collapsed state
expandedPosition number - Position when expanding from initial collapse
min number 10 Minimum position (%)
max number 90 Maximum position (%)
step number 5 Keyboard step size (%)
largeStep number 10 Shift+Arrow step size (%)
orientation 'horizontal' | 'vertical' 'horizontal' Splitter orientation
dir 'ltr' | 'rtl' - Text direction for RTL support
collapsible boolean true Allow collapse/expand with Enter
disabled boolean false Disabled state
readonly boolean false Readonly state (focusable but not operable)
onPositionChange (position: number, sizeInPx: number) => void - Callback when position changes
onCollapsedChange (collapsed: boolean, previousPosition: number) => void - Callback 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.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { WindowSplitter } from './WindowSplitter';

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

    it('has aria-valuenow representing primary pane percentage', () => {
      render(
        <WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
      );
      const splitter = screen.getByRole('separator');
      expect(splitter).toHaveAttribute('aria-valuenow', '50');
    });

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

    it('has aria-valuenow="0" when collapsed', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
      );
      const splitter = screen.getByRole('separator');

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

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

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

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

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

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

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

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

    it('has aria-controls with multiple IDs when secondaryPaneId provided', () => {
      render(
        <WindowSplitter
          primaryPaneId="primary"
          secondaryPaneId="secondary"
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');
      expect(splitter).toHaveAttribute('aria-controls', 'primary secondary');
    });

    it('does not have aria-orientation for horizontal splitter (default)', () => {
      render(<WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />);
      const splitter = screen.getByRole('separator');
      expect(splitter).not.toHaveAttribute('aria-orientation');
    });

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

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

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

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

    it('has accessible name via aria-labelledby', () => {
      render(
        <>
          <span id="splitter-label">Adjust panel size</span>
          <WindowSplitter primaryPaneId="primary" aria-labelledby="splitter-label" />
        </>
      );
      expect(screen.getByRole('separator', { name: 'Adjust panel size' })).toBeInTheDocument();
    });
  });

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

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

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

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

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

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

    it('ArrowUp does nothing on horizontal splitter', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          orientation="horizontal"
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

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

    it('ArrowDown does nothing on horizontal splitter', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          orientation="horizontal"
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

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

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

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

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

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

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

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

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

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

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

    it('decreases value by step on ArrowDown', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          step={5}
          orientation="vertical"
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

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

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

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

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

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

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

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

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

      await user.click(splitter);
      await user.keyboard('{Shift>}{ArrowUp}{/Shift}');

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

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

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

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

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

      await user.click(splitter);
      await user.keyboard('{Enter}'); // Collapse → 0
      await user.keyboard('{Enter}'); // Expand → 50

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

    it('expands to expandedPosition when initially collapsed', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultCollapsed
          expandedPosition={60}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      await user.click(splitter);
      await user.keyboard('{Enter}'); // Expand → 60

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

    it('expands to defaultPosition when initially collapsed without expandedPosition', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultCollapsed
          defaultPosition={40}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      await user.click(splitter);
      await user.keyboard('{Enter}'); // Expand → 40

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

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

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

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

    it('restores correct value after multiple collapse/expand cycles', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          step={5}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      await user.click(splitter);
      await user.keyboard('{ArrowRight}'); // 55
      await user.keyboard('{Enter}'); // Collapse → 0
      await user.keyboard('{Enter}'); // Expand → 55

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

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

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

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

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

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

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

    it('does not exceed max on ArrowRight', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={85}
          max={90}
          step={10}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    it('has tabindex="0" when readonly (focusable but not operable)', () => {
      render(<WindowSplitter primaryPaneId="primary" readonly aria-label="Resize panels" />);
      const splitter = screen.getByRole('separator');
      expect(splitter).toHaveAttribute('tabindex', '0');
    });

    it('is focusable via Tab', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <WindowSplitter primaryPaneId="primary" aria-label="Resize panels" />
          <button>After</button>
        </>
      );

      await user.tab(); // Focus "Before" button
      await user.tab(); // Focus splitter

      expect(screen.getByRole('separator')).toHaveFocus();
    });

    it('is not focusable via Tab when disabled', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <WindowSplitter primaryPaneId="primary" disabled aria-label="Resize panels" />
          <button>After</button>
        </>
      );

      await user.tab(); // Focus "Before" button
      await user.tab(); // Skip splitter, focus "After" button

      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
    });

    it('focus remains on splitter after collapse', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
      );
      const splitter = screen.getByRole('separator');

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

      expect(splitter).toHaveFocus();
    });
  });

  // 🟡 Medium Priority: Pointer Interaction
  describe('Pointer Interaction', () => {
    it('updates position on pointer down', () => {
      const handleChange = vi.fn();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });

      // Focus should be on splitter
      expect(splitter).toHaveFocus();
    });

    it('does not respond to pointer when disabled', () => {
      const handleChange = vi.fn();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          disabled
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });

      expect(handleChange).not.toHaveBeenCalled();
    });

    it('does not respond to pointer when readonly', () => {
      const handleChange = vi.fn();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          readonly
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

      fireEvent.pointerDown(splitter, { clientX: 100, clientY: 100 });

      expect(handleChange).not.toHaveBeenCalled();
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    // Helper: Render with pane elements for aria-controls validation
    const renderWithPanes = (
      splitterProps: Partial<Parameters<typeof WindowSplitter>[0]> & {
        'aria-label'?: string;
        'aria-labelledby'?: string;
      }
    ) => {
      return render(
        <>
          <div id="primary">Primary Pane</div>
          <WindowSplitter primaryPaneId="primary" {...splitterProps} />
          <div id="secondary">Secondary Pane</div>
        </>
      );
    };

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

    it('has no axe violations with aria-labelledby', async () => {
      const { container } = render(
        <>
          <span id="label">Resize panels</span>
          <div id="primary">Primary Pane</div>
          <WindowSplitter primaryPaneId="primary" aria-labelledby="label" />
        </>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

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

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

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

  // 🟡 Medium Priority: Callbacks
  describe('Callbacks', () => {
    it('calls onPositionChange on keyboard interaction', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          step={5}
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

      expect(handleChange).toHaveBeenCalled();
      expect(handleChange.mock.calls[0][0]).toBe(55);
    });

    it('calls onCollapsedChange on collapse', async () => {
      const handleCollapse = vi.fn();
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          onCollapsedChange={handleCollapse}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

      expect(handleCollapse).toHaveBeenCalledWith(true, 50);
    });

    it('calls onCollapsedChange on expand', async () => {
      const handleCollapse = vi.fn();
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultCollapsed
          defaultPosition={50}
          onCollapsedChange={handleCollapse}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

      expect(handleCollapse).toHaveBeenCalledWith(false, 0);
    });

    it('does not call onPositionChange when disabled', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={50}
          disabled
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

      expect(handleChange).not.toHaveBeenCalled();
    });

    it('does not call onPositionChange when value does not change', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={90}
          max={90}
          step={5}
          onPositionChange={handleChange}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

      expect(handleChange).not.toHaveBeenCalled();
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('clamps defaultPosition to min', () => {
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultPosition={5}
          min={10}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');
      expect(splitter).toHaveAttribute('aria-valuenow', '10');
    });

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

    it('clamps expandedPosition to min/max', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter
          primaryPaneId="primary"
          defaultCollapsed
          expandedPosition={95}
          max={90}
          aria-label="Resize panels"
        />
      );
      const splitter = screen.getByRole('separator');

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

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

    it('uses default step of 5', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
      );
      const splitter = screen.getByRole('separator');

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

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

    it('uses default largeStep of 10', async () => {
      const user = userEvent.setup();
      render(
        <WindowSplitter primaryPaneId="primary" defaultPosition={50} aria-label="Resize panels" />
      );
      const splitter = screen.getByRole('separator');

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

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

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

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

    it('supports aria-describedby', () => {
      render(
        <>
          <WindowSplitter
            primaryPaneId="primary"
            aria-label="Resize panels"
            aria-describedby="desc"
          />
          <p id="desc">Use arrow keys to resize, Enter to collapse</p>
        </>
      );
      const splitter = screen.getByRole('separator');
      expect(splitter).toHaveAttribute('aria-describedby', 'desc');
    });
  });
});

Resources