APG Patterns
日本語
日本語

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.

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

RoleTarget ElementDescription
separatorSplitter elementFocusable separator that controls pane size

WAI-ARIA Properties

aria-valuenow

Primary pane size as percentage

Values
0-100
Required
Yes

aria-valuemin

Minimum value (default: 10)

Values
number
Required
Yes

aria-valuemax

Maximum value (default: 90)

Values
number
Required
Yes

aria-controls

Primary pane ID (+ secondary pane ID optional)

Values
ID reference(s)
Required
Yes

aria-label

Accessible name

Values
string
Required
Conditional (required if no aria-labelledby)

aria-labelledby

Reference to visible label element

Values
ID reference
Required
Conditional (required if no aria-label)

aria-orientation

Default: horizontal (left-right split)

Values
horizontal | vertical
Required
No

aria-disabled

Disabled state

Values
true | false
Required
No

WAI-ARIA States

aria-valuenow

Target Element
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

Keyboard Support

KeyAction
Arrow Right / Arrow LeftMove horizontal splitter (increase/decrease)
Arrow Up / Arrow DownMove vertical splitter (increase/decrease)
Shift + ArrowMove by large step (default: 10%)
HomeMove to minimum position
EndMove to maximum position
EnterToggle collapse/expand primary pane
  • 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.
  • aria-readonly is NOT valid for role=“separator”. Readonly behavior must be enforced via JavaScript only.

Focus Management

EventBehavior
TabSplitter receives focus via normal tab order
DisabledSplitter is not focusable (tabindex="-1")
ReadonlySplitter is focusable but not operable
After collapse/expandFocus remains on splitter

References

Source Code

WindowSplitter.tsx
import { clsx } from 'clsx';
import type { CSSProperties } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';

interface SplitterStyle extends CSSProperties {
  '--splitter-position': string;
}

type LabelProps =
  | { 'aria-label': string; 'aria-labelledby'?: never }
  | { 'aria-label'?: never; 'aria-labelledby': string };

type WindowSplitterBaseProps = {
  primaryPaneId: string;
  secondaryPaneId?: string;
  defaultPosition?: number;
  defaultCollapsed?: boolean;
  expandedPosition?: number;
  min?: number;
  max?: number;
  step?: number;
  largeStep?: number;
  orientation?: 'horizontal' | 'vertical';
  dir?: 'ltr' | 'rtl';
  collapsible?: boolean;
  disabled?: boolean;
  readonly?: boolean;
  onPositionChange?: (position: number, sizeInPx: number) => void;
  onCollapsedChange?: (collapsed: boolean, previousPosition: number) => void;
  'aria-describedby'?: string;
  'data-testid'?: string;
  className?: string;
  id?: string;
};

export type WindowSplitterProps = WindowSplitterBaseProps & LabelProps;

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

const ChevronIcon = ({ d, dx = 0, dy = 0 }: { d: string; dx?: number; dy?: number }) => (
  <svg
    width="12"
    height="12"
    viewBox="0 0 12 12"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.5"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    style={dx || dy ? { transform: `translate(${dx}px, ${dy}px)` } : undefined}
  >
    <path d={d} />
  </svg>
);

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

type PopupState = 'hidden' | 'showing' | 'active';

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

  const [position, setPosition] = useState(initialPosition);
  const [collapsed, setCollapsed] = useState(defaultCollapsed);
  const [popupState, setPopupState] = useState<PopupState>('hidden');
  const [popupPos, setPopupPos] = useState<{ x: number; y: number } | null>(null);

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

  const isDraggingRef = useRef(false);
  const isMouseOverPopupRef = useRef(false);
  const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const activeSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
  const hoverPosRef = useRef<{ x: number; y: number } | null>(null);

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

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

  const splitterLabel = ariaLabel || '';

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

  const clearAllTimers = useCallback(() => {
    if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
    if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
    if (activeSettleTimerRef.current) clearTimeout(activeSettleTimerRef.current);
    hoverTimerRef.current = null;
    dismissTimerRef.current = null;
    activeSettleTimerRef.current = null;
  }, []);

  useEffect(() => {
    return () => clearAllTimers();
  }, [clearAllTimers]);

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

      let x: number;
      let y: number;

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

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

      return { x, y };
    },
    [isHorizontal, isVertical]
  );

  const showPopup = useCallback(
    (clientX: number, clientY: number) => {
      if (disabled || readonly) return;
      if (dismissTimerRef.current) {
        clearTimeout(dismissTimerRef.current);
        dismissTimerRef.current = null;
      }
      const pos = calcPopupPosition(clientX, clientY);
      if (pos) {
        setPopupPos(pos);
        setPopupState('showing');
      }
    },
    [disabled, readonly, calcPopupPosition]
  );

  const hidePopup = useCallback(() => {
    clearAllTimers();
    setPopupState('hidden');
    setPopupPos(null);
  }, [clearAllTimers]);

  useEffect(() => {
    if (popupState !== 'active') return;
    const handleOutsidePointerDown = (e: PointerEvent) => {
      const popup = popupRef.current;
      if (popup && e.target instanceof Node && !popup.contains(e.target)) {
        hidePopup();
      }
    };
    document.addEventListener('pointerdown', handleOutsidePointerDown);
    return () => document.removeEventListener('pointerdown', handleOutsidePointerDown);
  }, [popupState, hidePopup]);

  const scheduleDismiss = useCallback(() => {
    if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
    dismissTimerRef.current = setTimeout(() => {
      const splitter = splitterRef.current;
      const popup = popupRef.current;
      const hasFocusInside =
        popup?.contains(document.activeElement) || splitter === document.activeElement;
      if (!hasFocusInside) {
        hidePopup();
      }
    }, DISMISS_DELAY);
  }, [hidePopup]);

  const updatePosition = useCallback(
    (newPosition: number) => {
      const clampedPosition = clamp(newPosition, min, max);
      if (clampedPosition !== positionRef.current) {
        positionRef.current = clampedPosition;
        setPosition(clampedPosition);
        const container = containerRef.current;
        const sizeInPx = container
          ? (clampedPosition / 100) *
            (isHorizontal ? container.offsetWidth : container.offsetHeight)
          : 0;
        onPositionChange?.(clampedPosition, sizeInPx);
      }
    },
    [min, max, isHorizontal, onPositionChange]
  );

  const handlePopupButtonClick = useCallback(
    (delta: number) => {
      if (disabled || readonly) return;
      const currentPos = positionRef.current;
      const newPos = currentPos + delta;
      if (newPos < min || newPos > max) return;
      updatePosition(newPos);
      setPopupState('active');
      if (activeSettleTimerRef.current) clearTimeout(activeSettleTimerRef.current);
      activeSettleTimerRef.current = setTimeout(() => {
        setPopupState((prev) => {
          if (prev === 'active') {
            if (!isMouseOverPopupRef.current) {
              const pos = hoverPosRef.current;
              if (pos) {
                const newPopupPos = calcPopupPosition(pos.x, pos.y);
                if (newPopupPos) setPopupPos(newPopupPos);
              }
            }
            return 'showing';
          }
          return prev;
        });
      }, ACTIVE_SETTLE_DELAY);
    },
    [disabled, readonly, min, max, updatePosition, calcPopupPosition]
  );

  const handleToggleCollapse = useCallback(() => {
    if (!collapsible || disabled || readonly) return;
    const currentPos = positionRef.current;
    if (collapsed) {
      const restorePosition =
        previousPositionRef.current ?? expandedPosition ?? defaultPosition ?? 50;
      const clampedRestore = clamp(restorePosition, min, max);
      onCollapsedChange?.(false, currentPos);
      setCollapsed(false);
      positionRef.current = clampedRestore;
      setPosition(clampedRestore);
      const container = containerRef.current;
      const sizeInPx = container
        ? (clampedRestore / 100) * (isHorizontal ? container.offsetWidth : container.offsetHeight)
        : 0;
      onPositionChange?.(clampedRestore, sizeInPx);
    } else {
      previousPositionRef.current = currentPos;
      onCollapsedChange?.(true, currentPos);
      setCollapsed(true);
      positionRef.current = 0;
      setPosition(0);
      onPositionChange?.(0, 0);
    }
  }, [
    collapsed,
    collapsible,
    disabled,
    readonly,
    expandedPosition,
    defaultPosition,
    min,
    max,
    isHorizontal,
    onCollapsedChange,
    onPositionChange,
  ]);

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

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

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

      let delta = 0;
      let handled = false;

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

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

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

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

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

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

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

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

  const handlePointerDown = useCallback(
    (event: React.PointerEvent) => {
      if (disabled || readonly) return;
      event.preventDefault();
      const splitter = splitterRef.current;
      if (!splitter) return;

      pointerStartRef.current = { x: event.clientX, y: event.clientY };
      isDraggingRef.current = false;

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

      if (hoverTimerRef.current) {
        clearTimeout(hoverTimerRef.current);
        hoverTimerRef.current = null;
      }

      splitter.focus();
    },
    [disabled, readonly]
  );

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

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

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

      const demoContainerElement = container.closest('.apg-window-splitter-demo-container');
      const demoContainer =
        demoContainerElement instanceof HTMLElement ? demoContainerElement : null;
      const measureElement = demoContainer || container.parentElement || container;
      const rect = measureElement.getBoundingClientRect();

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

      const clampedPercent = clamp(percent, min, max);
      if (demoContainer) {
        demoContainer.style.setProperty('--splitter-position', `${clampedPercent}%`);
      }
      updatePosition(percent);
    },
    [isHorizontal, min, max, updatePosition, popupState, hidePopup]
  );

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

      const start = pointerStartRef.current;
      if (start && !isDraggingRef.current) {
        showPopup(start.x, start.y);
      }

      isDraggingRef.current = false;
      pointerStartRef.current = null;
    },
    [showPopup]
  );

  const handleSeparatorMouseEnter = useCallback(
    (event: React.MouseEvent) => {
      if (disabled || readonly || isDraggingRef.current) return;
      hoverPosRef.current = { x: event.clientX, y: event.clientY };
      if (popupState === 'hidden') {
        if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
        hoverTimerRef.current = setTimeout(() => {
          const pos = hoverPosRef.current;
          if (pos) showPopup(pos.x, pos.y);
        }, HOVER_DELAY);
      }
      if (dismissTimerRef.current) {
        clearTimeout(dismissTimerRef.current);
        dismissTimerRef.current = null;
      }
    },
    [disabled, readonly, popupState, showPopup]
  );

  const handleSeparatorMouseLeave = useCallback(() => {
    if (hoverTimerRef.current) {
      clearTimeout(hoverTimerRef.current);
      hoverTimerRef.current = null;
    }
    if (popupState !== 'hidden') scheduleDismiss();
  }, [popupState, scheduleDismiss]);

  const handleSeparatorMouseMove = useCallback((event: React.MouseEvent) => {
    hoverPosRef.current = { x: event.clientX, y: event.clientY };
  }, []);

  const handleSeparatorFocus = useCallback(() => {
    if (disabled || readonly) return;
    if (popupState === 'hidden') {
      const splitter = splitterRef.current;
      if (splitter) {
        const rect = splitter.getBoundingClientRect();
        showPopup(rect.left + rect.width / 2, rect.top + rect.height / 2);
      }
    }
    if (dismissTimerRef.current) {
      clearTimeout(dismissTimerRef.current);
      dismissTimerRef.current = null;
    }
  }, [disabled, readonly, popupState, showPopup]);

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

  const handlePopupMouseEnter = useCallback(() => {
    isMouseOverPopupRef.current = true;
    if (dismissTimerRef.current) {
      clearTimeout(dismissTimerRef.current);
      dismissTimerRef.current = null;
    }
  }, []);

  const handlePopupMouseLeave = useCallback(() => {
    isMouseOverPopupRef.current = false;
    if (popupState !== 'hidden') scheduleDismiss();
  }, [popupState, scheduleDismiss]);

  const handlePopupKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      if (event.key === 'Escape') {
        event.preventDefault();
        hidePopup();
        splitterRef.current?.focus();
      }
      if (event.key === 'Tab' && event.shiftKey) {
        event.preventDefault();
        hidePopup();
        splitterRef.current?.focus();
      }
    },
    [hidePopup]
  );

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

  const isAtMin = position <= min;
  const isAtMax = position >= max;

  return (
    <div
      ref={containerRef}
      className={clsx(
        'apg-window-splitter',
        isVertical && 'apg-window-splitter--vertical',
        disabled && 'apg-window-splitter--disabled',
        className
      )}
      style={{ '--splitter-position': `${position}%` } satisfies SplitterStyle}
    >
      {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
      <div
        ref={splitterRef}
        role="separator"
        id={id}
        tabIndex={disabled ? -1 : 0}
        aria-valuenow={position}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-controls={ariaControls}
        aria-orientation={isVertical ? 'vertical' : undefined}
        aria-disabled={disabled ? true : undefined}
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledby}
        aria-describedby={ariaDescribedby}
        data-testid={dataTestid}
        className="apg-window-splitter__separator"
        onKeyDown={handleKeyDown}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
        onMouseEnter={handleSeparatorMouseEnter}
        onMouseLeave={handleSeparatorMouseLeave}
        onMouseMove={handleSeparatorMouseMove}
        onFocus={handleSeparatorFocus}
        onBlur={handleSeparatorBlur}
      />
      {!disabled && !readonly && (
        /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */
        <div
          ref={popupRef}
          role="group"
          aria-label={`Adjust ${splitterLabel}`}
          className={clsx(
            'apg-window-splitter__popup',
            popupState !== 'hidden' && 'apg-window-splitter__popup--visible'
          )}
          style={
            popupPos
              ? {
                  left: popupPos.x,
                  top: popupPos.y,
                  flexDirection: isVertical ? ('column' as const) : ('row' as const),
                }
              : undefined
          }
          onMouseEnter={handlePopupMouseEnter}
          onMouseLeave={handlePopupMouseLeave}
          onKeyDown={handlePopupKeyDown}
        >
          <button
            type="button"
            className="apg-window-splitter__popup-button"
            tabIndex={-1}
            aria-label={`Collapse ${splitterLabel}`}
            aria-disabled={isAtMin}
            onClick={() => !isAtMin && handlePopupButtonClick(min - positionRef.current)}
          >
            <ChevronIcon {...icons.collapse} />
          </button>
          <button
            type="button"
            className="apg-window-splitter__popup-button"
            tabIndex={-1}
            aria-label={`Shrink ${splitterLabel}`}
            aria-disabled={isAtMin}
            onClick={() => !isAtMin && handlePopupButtonClick(-step)}
          >
            <ChevronIcon {...icons.shrink} />
          </button>
          <button
            type="button"
            className="apg-window-splitter__popup-button"
            tabIndex={-1}
            aria-label={`Expand ${splitterLabel}`}
            aria-disabled={isAtMax}
            onClick={() => !isAtMax && handlePopupButtonClick(step)}
          >
            <ChevronIcon {...icons.expand} />
          </button>
          <button
            type="button"
            className="apg-window-splitter__popup-button"
            tabIndex={-1}
            aria-label={`Expand ${splitterLabel} to maximum`}
            aria-disabled={isAtMax}
            onClick={() => !isAtMax && handlePopupButtonClick(max - positionRef.current)}
          >
            <ChevronIcon {...icons.max} />
          </button>
        </div>
      )}
    </div>
  );
};

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

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/windowsplitter/

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

E2E Tests

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

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

Testing Tools

See the Testing Strategy guide for details.

WindowSplitter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { WindowSplitter } from './WindowSplitter';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Resources