APG Patterns
日本語
日本語

Slider (Multi-Thumb)

A slider with two thumbs that allows users to select a range of values within a given range.

Demo

Price Range
Temperature Range
Budget Range (with minDistance)
Disabled

Open demo only →

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
slider Lower thumb element Identifies the element as a slider for selecting the lower bound of the range.
slider Upper thumb element Identifies the element as a slider for selecting the upper bound of the range.
group Container element Groups the two sliders together and associates them with a common label.

Each thumb is an independent slider element with its own ARIA attributes. The group role establishes the semantic relationship between the two sliders.

WAI-ARIA Properties

aria-valuenow (Required)

Indicates the current numeric value of each thumb. Updated dynamically as the user changes the value.

Type Number
Required Yes
Range Must be between aria-valuemin and aria-valuemax

aria-valuemin (Required)

Dynamic bounds: Per the APG specification, when the range of one slider depends on the value of another, these attributes must update dynamically:

Thumb aria-valuemin aria-valuemax
Lower thumb Static (absolute min) Dynamic (upper value - minDistance)
Upper thumb Dynamic (lower value + minDistance) Static (absolute max)

This approach correctly communicates to assistive technology users the actual allowed range for each thumb at any given moment, which enables better predictability of Home and End key behaviors.

aria-valuemax (Required)

aria-valuetext

Provides a human-readable text alternative for the current value. Use when the numeric value alone doesn't convey sufficient meaning.

Type String
Required No (recommended when value needs context)
Example "$20", "$80", "20% - 80%"

aria-orientation

Specifies the orientation of the slider. Only set to "vertical" for vertical sliders; omit for horizontal (default).

Type "horizontal" | "vertical"
Required No
Default horizontal (implicit)

aria-disabled

Indicates that the slider is disabled and not interactive.

Type true | undefined
Required No

Keyboard Support

Key Action
Tab Moves focus between thumbs (lower to upper)
Shift + Tab Moves focus between thumbs (upper to lower)
Right Arrow Increases the value by one step
Up Arrow Increases the value by one step
Left Arrow Decreases the value by one step
Down Arrow Decreases the value by one step
Home Sets the thumb to its minimum allowed value (dynamic for upper thumb)
End Sets the thumb to its maximum allowed value (dynamic for lower thumb)
Page Up Increases the value by a large step (default: step * 10)
Page Down Decreases the value by a large step (default: step * 10)

Collision Prevention

The multi-thumb slider ensures that the thumbs cannot cross each other:

  • Lower thumb - Cannot exceed (upper value - minDistance)
  • Upper thumb - Cannot go below (lower value + minDistance)
  • minDistance - Configurable minimum gap between thumbs (default: 0)

Accessible Naming

Multi-thumb sliders require careful labeling to distinguish between the two thumbs. This implementation supports several approaches:

  • Visible group label - Using the label prop to display a visible label for the slider group
  • aria-label (tuple) - Provides individual labels for each thumb, e.g., ["Minimum Price", "Maximum Price"]
  • aria-labelledby (tuple) - References external elements as labels for each thumb
  • getAriaLabel function - Dynamic label generation based on thumb index

Focus Management

Focus behavior in this implementation:

  • Tab order - Both thumbs are in the tab order (tabindex="0")
  • Constant order - Lower thumb always comes first in tab order, regardless of values
  • Track click - Clicking the track moves the nearest thumb and focuses it

Pointer Interaction

This implementation supports mouse and touch interaction:

  • Click on track: Moves the nearest thumb to the clicked position
  • Drag thumb: Allows continuous adjustment while dragging
  • Pointer capture: Maintains interaction even when pointer moves outside the slider

Visual Design

This implementation follows WCAG guidelines for accessible visual design:

  • Focus indicator: Visible focus ring on each thumb element
  • Range indicator: Visual representation of the selected range between thumbs
  • Hover states: Visual feedback on hover
  • Disabled state: Clear visual indication when slider is disabled
  • Forced colors mode: Uses system colors for accessibility in Windows High Contrast Mode

References

Source Code

MultiThumbSlider.tsx
import { clsx } from 'clsx';
import { useCallback, useEffect, useId, useRef, useState } from 'react';

// Label props: one of these required
type ThumbLabelProps =
  | { 'aria-label': [string, string]; 'aria-labelledby'?: never; getAriaLabel?: never }
  | { 'aria-label'?: never; 'aria-labelledby': [string, string]; getAriaLabel?: never }
  | { 'aria-label'?: never; 'aria-labelledby'?: never; getAriaLabel: (index: number) => string };

type MultiThumbSliderBaseProps = {
  /** Controlled values [lowerValue, upperValue] */
  value?: [number, number];
  /** Initial values for uncontrolled mode [lowerValue, upperValue] */
  defaultValue?: [number, number];
  /** Minimum value (default: 0) */
  min?: number;
  /** Maximum value (default: 100) */
  max?: number;
  /** Step increment (default: 1) */
  step?: number;
  /** Large step for PageUp/PageDown (default: step * 10) */
  largeStep?: number;
  /** Minimum distance between thumbs (default: 0) */
  minDistance?: number;
  /** Slider orientation */
  orientation?: 'horizontal' | 'vertical';
  /** Whether slider is disabled */
  disabled?: boolean;
  /** Show value text (default: true) */
  showValues?: boolean;
  /** Format pattern for value display (e.g., "${value}") */
  format?: string;
  /** Function to get aria-valuetext per thumb */
  getAriaValueText?: (value: number, index: number) => string;
  /** Visible label for the group */
  label?: string;
  /** Callback when value changes */
  onValueChange?: (values: [number, number], activeThumbIndex: number) => void;
  /** Callback when change is committed (pointer up / blur) */
  onValueCommit?: (values: [number, number]) => void;
  /** Container className */
  className?: string;
  /** Container id */
  id?: string;
  /** aria-describedby per thumb (tuple or single for both) */
  'aria-describedby'?: string | [string, string];
  /** Test id */
  'data-testid'?: string;
};

export type MultiThumbSliderProps = MultiThumbSliderBaseProps & ThumbLabelProps;

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

const roundToStep = (value: number, step: number, min: number): number => {
  const steps = Math.round((value - min) / step);
  const result = min + steps * step;
  const decimalPlaces = (step.toString().split('.')[1] || '').length;
  return Number(result.toFixed(decimalPlaces));
};

const getPercent = (value: number, min: number, max: number): number => {
  if (max === min) return 0;
  return ((value - min) / (max - min)) * 100;
};

const formatValue = (
  value: number,
  formatStr: string | undefined,
  min: number,
  max: number
): string => {
  if (!formatStr) return String(value);
  return formatStr
    .replace('{value}', String(value))
    .replace('{min}', String(min))
    .replace('{max}', String(max));
};

// Get dynamic bounds for a thumb
const getThumbBounds = (
  index: number,
  values: [number, number],
  min: number,
  max: number,
  minDistance: number
): { min: number; max: number } => {
  // Guard against impossible constraints
  const effectiveMinDistance = Math.min(minDistance, max - min);
  if (index === 0) {
    return { min: min, max: values[1] - effectiveMinDistance };
  } else {
    return { min: values[0] + effectiveMinDistance, max: max };
  }
};

// Normalize values to ensure they are valid
const normalizeValues = (
  values: [number, number],
  min: number,
  max: number,
  step: number,
  minDistance: number
): [number, number] => {
  let [lower, upper] = values;

  // Guard against impossible constraints (minDistance larger than range)
  const effectiveMinDistance = Math.min(minDistance, max - min);

  // Round to step
  lower = roundToStep(lower, step, min);
  upper = roundToStep(upper, step, min);

  // Clamp to absolute bounds
  lower = clamp(lower, min, max - effectiveMinDistance);
  upper = clamp(upper, min + effectiveMinDistance, max);

  // Ensure lower <= upper - effectiveMinDistance
  if (lower > upper - effectiveMinDistance) {
    lower = upper - effectiveMinDistance;
  }

  return [lower, upper];
};

export const MultiThumbSlider: React.FC<MultiThumbSliderProps> = ({
  value: controlledValue,
  defaultValue,
  min = 0,
  max = 100,
  step = 1,
  largeStep,
  minDistance = 0,
  orientation = 'horizontal',
  disabled = false,
  showValues = true,
  format,
  getAriaValueText,
  label,
  onValueChange,
  onValueCommit,
  className,
  id,
  'aria-describedby': ariaDescribedby,
  'data-testid': dataTestId,
  ...rest
}) => {
  // Calculate initial values
  const initialValues = normalizeValues(defaultValue ?? [min, max], min, max, step, minDistance);

  const [internalValues, setInternalValues] = useState<[number, number]>(initialValues);
  const values = controlledValue ?? internalValues;

  // Ref to track latest values for onValueCommit (avoids stale closure)
  const valuesRef = useRef<[number, number]>(values);
  useEffect(() => {
    valuesRef.current = values;
  }, [values]);

  const lowerThumbRef = useRef<HTMLDivElement>(null);
  const upperThumbRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const groupLabelId = useId();
  const isVertical = orientation === 'vertical';
  const effectiveLargeStep = largeStep ?? step * 10;

  // Active thumb during drag
  const activeThumbRef = useRef<number | null>(null);

  // Helper to get thumb ref by index
  const getThumbRef = useCallback(
    (index: number) => (index === 0 ? lowerThumbRef : upperThumbRef),
    []
  );

  // Update values
  const updateValues = useCallback(
    (newValues: [number, number], activeIndex: number) => {
      if (!controlledValue) {
        setInternalValues(newValues);
      }
      onValueChange?.(newValues, activeIndex);
    },
    [controlledValue, onValueChange]
  );

  // Update a single thumb value
  const updateThumbValue = useCallback(
    (index: number, newValue: number) => {
      const bounds = getThumbBounds(index, values, min, max, minDistance);
      const rounded = roundToStep(newValue, step, min);
      const clamped = clamp(rounded, bounds.min, bounds.max);

      if (clamped === values[index]) return; // No change

      const newValues: [number, number] = [...values];
      newValues[index] = clamped;
      updateValues(newValues, index);
    },
    [values, min, max, step, minDistance, updateValues]
  );

  // Keyboard handler for a specific thumb
  const handleKeyDown = useCallback(
    (index: number) => (event: React.KeyboardEvent) => {
      if (disabled) return;

      const bounds = getThumbBounds(index, values, min, max, minDistance);
      let newValue = values[index];

      switch (event.key) {
        case 'ArrowRight':
        case 'ArrowUp':
          newValue = values[index] + step;
          break;
        case 'ArrowLeft':
        case 'ArrowDown':
          newValue = values[index] - step;
          break;
        case 'Home':
          newValue = bounds.min;
          break;
        case 'End':
          newValue = bounds.max;
          break;
        case 'PageUp':
          newValue = values[index] + effectiveLargeStep;
          break;
        case 'PageDown':
          newValue = values[index] - effectiveLargeStep;
          break;
        default:
          return;
      }

      event.preventDefault();
      updateThumbValue(index, newValue);
    },
    [values, min, max, step, effectiveLargeStep, minDistance, disabled, updateThumbValue]
  );

  // Calculate value from pointer position
  const getValueFromPointer = useCallback(
    (clientX: number, clientY: number): number => {
      const track = trackRef.current;
      if (!track) return values[0];

      const rect = track.getBoundingClientRect();

      if (rect.width === 0 && rect.height === 0) {
        return values[0];
      }

      let percent: number;

      if (isVertical) {
        if (rect.height === 0) return values[0];
        percent = 1 - (clientY - rect.top) / rect.height;
      } else {
        if (rect.width === 0) return values[0];
        percent = (clientX - rect.left) / rect.width;
      }

      const rawValue = min + percent * (max - min);
      return roundToStep(rawValue, step, min);
    },
    [isVertical, min, max, step, values]
  );

  // Pointer handlers for thumb drag
  const handleThumbPointerDown = useCallback(
    (index: number) => (event: React.PointerEvent) => {
      if (disabled) return;

      event.preventDefault();
      const thumb = getThumbRef(index).current;
      if (!thumb) return;

      if (typeof thumb.setPointerCapture === 'function') {
        thumb.setPointerCapture(event.pointerId);
      }
      activeThumbRef.current = index;
      thumb.focus();
    },
    [disabled, getThumbRef]
  );

  const handleThumbPointerMove = useCallback(
    (index: number) => (event: React.PointerEvent) => {
      const thumb = getThumbRef(index).current;
      if (!thumb) return;

      const hasCapture =
        typeof thumb.hasPointerCapture === 'function'
          ? thumb.hasPointerCapture(event.pointerId)
          : activeThumbRef.current === index;

      if (!hasCapture) return;

      const newValue = getValueFromPointer(event.clientX, event.clientY);
      updateThumbValue(index, newValue);
    },
    [getThumbRef, getValueFromPointer, updateThumbValue]
  );

  const handleThumbPointerUp = useCallback(
    (index: number) => (event: React.PointerEvent) => {
      const thumb = getThumbRef(index).current;
      if (thumb && typeof thumb.releasePointerCapture === 'function') {
        try {
          thumb.releasePointerCapture(event.pointerId);
        } catch {
          // Ignore
        }
      }
      activeThumbRef.current = null;
      // Use ref to get latest values (avoids stale closure issue)
      onValueCommit?.(valuesRef.current);
    },
    [getThumbRef, onValueCommit]
  );

  // Track click handler
  const handleTrackClick = useCallback(
    (event: React.MouseEvent) => {
      if (disabled) return;

      // Ignore if clicked on a thumb
      if (event.target === lowerThumbRef.current || event.target === upperThumbRef.current) {
        return;
      }

      const clickValue = getValueFromPointer(event.clientX, event.clientY);

      // Determine which thumb to move (nearest, prefer lower on tie)
      const distToLower = Math.abs(clickValue - values[0]);
      const distToUpper = Math.abs(clickValue - values[1]);
      const activeIndex = distToLower <= distToUpper ? 0 : 1;

      updateThumbValue(activeIndex, clickValue);
      getThumbRef(activeIndex).current?.focus();
    },
    [disabled, getThumbRef, getValueFromPointer, values, updateThumbValue]
  );

  // Calculate percentages for positioning
  const lowerPercent = getPercent(values[0], min, max);
  const upperPercent = getPercent(values[1], min, max);

  // Get aria-label for a thumb
  const getThumbAriaLabel = (index: number): string | undefined => {
    if ('aria-label' in rest && rest['aria-label']) {
      return rest['aria-label'][index];
    }
    if ('getAriaLabel' in rest && rest.getAriaLabel) {
      return rest.getAriaLabel(index);
    }
    return undefined;
  };

  // Get aria-labelledby for a thumb
  const getThumbAriaLabelledby = (index: number): string | undefined => {
    if ('aria-labelledby' in rest && rest['aria-labelledby']) {
      return rest['aria-labelledby'][index];
    }
    return undefined;
  };

  // Get aria-describedby for a thumb
  const getThumbAriaDescribedby = (index: number): string | undefined => {
    if (!ariaDescribedby) return undefined;
    if (Array.isArray(ariaDescribedby)) {
      return ariaDescribedby[index];
    }
    return ariaDescribedby;
  };

  // Get aria-valuetext for a thumb
  const getThumbAriaValueText = (index: number): string | undefined => {
    const value = values[index];
    if (getAriaValueText) {
      return getAriaValueText(value, index);
    }
    if (format) {
      return formatValue(value, format, min, max);
    }
    return undefined;
  };

  // Get display text for a value
  const getDisplayText = (index: number): string => {
    return formatValue(values[index], format, min, max);
  };

  // Get bounds for a thumb
  const getLowerBounds = () => getThumbBounds(0, values, min, max, minDistance);
  const getUpperBounds = () => getThumbBounds(1, values, min, max, minDistance);

  return (
    <div
      role={label ? 'group' : undefined}
      aria-labelledby={label ? groupLabelId : undefined}
      className={clsx(
        'apg-slider-multithumb',
        isVertical && 'apg-slider-multithumb--vertical',
        disabled && 'apg-slider-multithumb--disabled',
        className
      )}
      id={id}
      data-testid={dataTestId}
    >
      {label && (
        <span id={groupLabelId} className="apg-slider-multithumb-label">
          {label}
        </span>
      )}
      {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
      <div
        ref={trackRef}
        className="apg-slider-multithumb-track"
        /* eslint-disable @typescript-eslint/consistent-type-assertions -- CSS custom properties require type assertion */
        style={
          {
            '--slider-lower': `${lowerPercent}%`,
            '--slider-upper': `${upperPercent}%`,
          } as React.CSSProperties
        }
        /* eslint-enable @typescript-eslint/consistent-type-assertions */
        onClick={handleTrackClick}
      >
        <div className="apg-slider-multithumb-range" aria-hidden="true" />
        {/* Lower thumb */}
        <div
          ref={lowerThumbRef}
          role="slider"
          tabIndex={disabled ? -1 : 0}
          aria-valuenow={values[0]}
          aria-valuemin={min}
          aria-valuemax={getLowerBounds().max}
          aria-valuetext={getThumbAriaValueText(0)}
          aria-label={getThumbAriaLabel(0)}
          aria-labelledby={getThumbAriaLabelledby(0)}
          aria-orientation={isVertical ? 'vertical' : undefined}
          aria-disabled={disabled ? true : undefined}
          aria-describedby={getThumbAriaDescribedby(0)}
          className="apg-slider-multithumb-thumb apg-slider-multithumb-thumb--lower"
          style={isVertical ? { bottom: `${lowerPercent}%` } : { left: `${lowerPercent}%` }}
          onKeyDown={handleKeyDown(0)}
          onPointerDown={handleThumbPointerDown(0)}
          onPointerMove={handleThumbPointerMove(0)}
          onPointerUp={handleThumbPointerUp(0)}
        >
          <span className="apg-slider-multithumb-tooltip" aria-hidden="true">
            {getThumbAriaLabel(0)}
          </span>
        </div>
        {/* Upper thumb */}
        <div
          ref={upperThumbRef}
          role="slider"
          tabIndex={disabled ? -1 : 0}
          aria-valuenow={values[1]}
          aria-valuemin={getUpperBounds().min}
          aria-valuemax={max}
          aria-valuetext={getThumbAriaValueText(1)}
          aria-label={getThumbAriaLabel(1)}
          aria-labelledby={getThumbAriaLabelledby(1)}
          aria-orientation={isVertical ? 'vertical' : undefined}
          aria-disabled={disabled ? true : undefined}
          aria-describedby={getThumbAriaDescribedby(1)}
          className="apg-slider-multithumb-thumb apg-slider-multithumb-thumb--upper"
          style={isVertical ? { bottom: `${upperPercent}%` } : { left: `${upperPercent}%` }}
          onKeyDown={handleKeyDown(1)}
          onPointerDown={handleThumbPointerDown(1)}
          onPointerMove={handleThumbPointerMove(1)}
          onPointerUp={handleThumbPointerUp(1)}
        >
          <span className="apg-slider-multithumb-tooltip" aria-hidden="true">
            {getThumbAriaLabel(1)}
          </span>
        </div>
      </div>
      {showValues && (
        <div className="apg-slider-multithumb-values" aria-hidden="true">
          <span className="apg-slider-multithumb-value apg-slider-multithumb-value--lower">
            {getDisplayText(0)}
          </span>
          <span className="apg-slider-multithumb-value-separator"> - </span>
          <span className="apg-slider-multithumb-value apg-slider-multithumb-value--upper">
            {getDisplayText(1)}
          </span>
        </div>
      )}
    </div>
  );
};

Usage

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

function App() {
  return (
    <div>
      {/* Basic usage with visible label and aria-label tuple */}
      <MultiThumbSlider
        defaultValue={[20, 80]}
        label="Price Range"
        aria-label={['Minimum Price', 'Maximum Price']}
      />

      {/* With format for display and aria-valuetext */}
      <MultiThumbSlider
        defaultValue={[25, 75]}
        label="Temperature"
        format="{value}°C"
        aria-label={['Min Temp', 'Max Temp']}
      />

      {/* With minDistance to prevent thumbs from getting too close */}
      <MultiThumbSlider
        defaultValue={[30, 70]}
        minDistance={10}
        label="Budget"
        format="${value}"
        aria-label={['Min Budget', 'Max Budget']}
      />

      {/* Custom range with step */}
      <MultiThumbSlider
        defaultValue={[200, 800]}
        min={0}
        max={1000}
        step={50}
        label="Price Filter"
        format="${value}"
        aria-label={['Min Price', 'Max Price']}
      />

      {/* With callbacks */}
      <MultiThumbSlider
        defaultValue={[20, 80]}
        label="Range"
        aria-label={['Lower', 'Upper']}
        onValueChange={(values, activeIndex) => {
          console.log('Changed:', values, 'Active thumb:', activeIndex);
        }}
        onValueCommit={(values) => {
          console.log('Committed:', values);
        }}
      />

      {/* Using aria-labelledby for external labels */}
      <span id="min-label">Minimum</span>
      <span id="max-label">Maximum</span>
      <MultiThumbSlider
        defaultValue={[20, 80]}
        aria-labelledby={['min-label', 'max-label']}
      />
    </div>
  );
}

API

Prop Type Default Description
defaultValue [number, number] [min, max] Initial values for the two thumbs [lower, upper]
min number 0 Minimum value (absolute)
max number 100 Maximum value (absolute)
step number 1 Step increment for keyboard navigation
largeStep number step × 10 Large step for PageUp/PageDown
minDistance number 0 Minimum distance between the two thumbs
orientation 'horizontal' | 'vertical' 'horizontal' Slider orientation
disabled boolean false Whether the slider is disabled
showValues boolean true Whether to display the value text
label string - Visible label for the slider group
format string - Format pattern for display and aria-valuetext (e.g., "{value}%", "${value}")
aria-label [string, string] - Accessible labels for each thumb [lower, upper]
aria-labelledby [string, string] - IDs of external label elements [lower, upper]
getAriaValueText (value, index) => string - Function to generate aria-valuetext dynamically
getAriaLabel (index) => string - Function to generate aria-label dynamically
onValueChange (values, activeIndex) => void - Callback when any value changes
onValueCommit (values) => void - Callback when interaction ends (drag ends, key up)

Note: Either aria-label, aria-labelledby, or getAriaLabel is required to provide accessible names for each thumb.

Testing

Tests verify APG compliance for ARIA attributes, keyboard interactions, collision prevention, and accessibility requirements for multi-thumb sliders.

Test Categories

High Priority: ARIA Structure

Test Description
two slider elements Container has exactly two elements with role="slider"
role="group" Sliders are contained in a group with aria-labelledby
aria-valuenow Both thumbs have correct initial values
static aria-valuemin Lower thumb has static min (absolute minimum)
static aria-valuemax Upper thumb has static max (absolute maximum)
dynamic aria-valuemax Lower thumb's max depends on upper thumb's value
dynamic aria-valuemin Upper thumb's min depends on lower thumb's value

High Priority: Dynamic Bounds Update

Test Description
lower -> upper bound Moving lower thumb updates upper thumb's aria-valuemin
upper -> lower bound Moving upper thumb updates lower thumb's aria-valuemax

High Priority: Keyboard Interaction

Test Description
Arrow Right/Up Increases value by one step
Arrow Left/Down Decreases value by one step
Home (lower) Sets lower thumb to absolute minimum
End (lower) Sets lower thumb to dynamic maximum (upper - minDistance)
Home (upper) Sets upper thumb to dynamic minimum (lower + minDistance)
End (upper) Sets upper thumb to absolute maximum
Page Up/Down Increases/decreases value by large step

High Priority: Collision Prevention

Test Description
lower cannot exceed upper Lower thumb stops at (upper - minDistance)
upper cannot go below lower Upper thumb stops at (lower + minDistance)
rapid key presses Thumbs do not cross when rapidly pressing arrow keys

High Priority: Focus Management

Test Description
Tab order Tab moves from lower to upper thumb
Shift+Tab order Shift+Tab moves from upper to lower thumb
tabindex="0" Both thumbs have tabindex="0" (always in tab order)

Medium Priority: aria-valuetext Updates

Test Description
lower thumb update aria-valuetext updates when lower thumb value changes
upper thumb update aria-valuetext updates when upper thumb value changes

Medium Priority: Accessibility

Test Description
axe violations (container) No accessibility violations on the container
axe violations (sliders) No accessibility violations on each slider element

Low Priority: Cross-framework Consistency

Test Description
render two sliders All frameworks render exactly two slider elements
consistent initial values All frameworks have identical initial aria-valuenow values
keyboard navigation All frameworks support identical keyboard navigation
collision prevention All frameworks prevent thumb crossing

Example Test Code

The following is the actual E2E test file (e2e/slider-multithumb.spec.ts).

e2e/slider-multithumb.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Multi-Thumb Slider Pattern
 *
 * A slider with two thumbs that allows users to select a range of values.
 * Each thumb uses role="slider" with dynamic aria-valuemin/aria-valuemax
 * based on the other thumb's position.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/slider-multithumb/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// ============================================
// Helper Functions
// ============================================

const getBasicSliderContainer = (page: import('@playwright/test').Page) => {
  return page.getByTestId('basic-slider');
};

const getSliders = (page: import('@playwright/test').Page) => {
  return getBasicSliderContainer(page).getByRole('slider');
};

const getSliderByLabel = (page: import('@playwright/test').Page, label: string) => {
  return getBasicSliderContainer(page).getByRole('slider', { name: label });
};

// ============================================
// Framework-specific Tests
// ============================================

for (const framework of frameworks) {
  test.describe(`MultiThumbSlider (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/slider-multithumb/${framework}/demo/`);
      await getSliders(page).first().waitFor();

      // Wait for hydration - sliders should have aria-valuenow
      const firstSlider = getSliders(page).first();
      await expect
        .poll(async () => {
          const valuenow = await firstSlider.getAttribute('aria-valuenow');
          return valuenow !== null;
        })
        .toBe(true);
    });

    // ------------------------------------------
    // 🔴 High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('has two slider elements', async ({ page }) => {
        const sliders = getSliders(page);
        await expect(sliders).toHaveCount(2);
      });

      test('lower thumb has role="slider"', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        await expect(lowerThumb).toHaveRole('slider');
      });

      test('upper thumb has role="slider"', async ({ page }) => {
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        await expect(upperThumb).toHaveRole('slider');
      });

      test('lower thumb has correct initial aria-valuenow', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const valuenow = await lowerThumb.getAttribute('aria-valuenow');
        expect(valuenow).toBe('20');
      });

      test('upper thumb has correct initial aria-valuenow', async ({ page }) => {
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        const valuenow = await upperThumb.getAttribute('aria-valuenow');
        expect(valuenow).toBe('80');
      });

      test('lower thumb has static aria-valuemin (absolute min)', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        await expect(lowerThumb).toHaveAttribute('aria-valuemin', '0');
      });

      test('upper thumb has static aria-valuemax (absolute max)', async ({ page }) => {
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        await expect(upperThumb).toHaveAttribute('aria-valuemax', '100');
      });

      test('lower thumb has dynamic aria-valuemax based on upper thumb', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        // Upper thumb is at 80, so lower thumb max should be 80 (or 80 - minDistance)
        const valuemax = await lowerThumb.getAttribute('aria-valuemax');
        expect(Number(valuemax)).toBeLessThanOrEqual(80);
      });

      test('upper thumb has dynamic aria-valuemin based on lower thumb', async ({ page }) => {
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        // Lower thumb is at 20, so upper thumb min should be 20 (or 20 + minDistance)
        const valuemin = await upperThumb.getAttribute('aria-valuemin');
        expect(Number(valuemin)).toBeGreaterThanOrEqual(20);
      });

      test('sliders are contained in group with label', async ({ page }) => {
        const group = page.getByRole('group', { name: 'Price Range' });
        await expect(group).toBeVisible();
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Dynamic Bounds Update
    // ------------------------------------------
    test.describe('APG: Dynamic Bounds Update', () => {
      test('moving lower thumb updates upper thumb aria-valuemin', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const upperThumb = getSliderByLabel(page, 'Maximum Price');

        await lowerThumb.click();
        await page.keyboard.press('ArrowRight');

        // Lower thumb moved from 20 to 21
        await expect(lowerThumb).toHaveAttribute('aria-valuenow', '21');

        // Upper thumb's min should have increased
        const valuemin = await upperThumb.getAttribute('aria-valuemin');
        expect(Number(valuemin)).toBeGreaterThanOrEqual(21);
      });

      test('moving upper thumb updates lower thumb aria-valuemax', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const upperThumb = getSliderByLabel(page, 'Maximum Price');

        await upperThumb.click();
        await page.keyboard.press('ArrowLeft');

        // Upper thumb moved from 80 to 79
        await expect(upperThumb).toHaveAttribute('aria-valuenow', '79');

        // Lower thumb's max should have decreased
        const valuemax = await lowerThumb.getAttribute('aria-valuemax');
        expect(Number(valuemax)).toBeLessThanOrEqual(79);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Interaction
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction', () => {
      test('ArrowRight increases lower thumb value by step', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        await lowerThumb.click();
        await expect(lowerThumb).toBeFocused();

        const initialValue = await lowerThumb.getAttribute('aria-valuenow');
        await page.keyboard.press('ArrowRight');

        const newValue = await lowerThumb.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) + 1);
      });

      test('ArrowLeft decreases upper thumb value by step', async ({ page }) => {
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        await upperThumb.click();

        const initialValue = await upperThumb.getAttribute('aria-valuenow');
        await page.keyboard.press('ArrowLeft');

        const newValue = await upperThumb.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) - 1);
      });

      test('Home sets lower thumb to absolute minimum', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        await lowerThumb.click();

        await page.keyboard.press('Home');

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

      test('End sets lower thumb to dynamic maximum (not absolute)', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        await lowerThumb.click();

        // Get upper thumb value to determine expected max
        const upperValue = await upperThumb.getAttribute('aria-valuenow');

        await page.keyboard.press('End');

        // Lower thumb should be at or near upper thumb value (respecting minDistance)
        const newValue = await lowerThumb.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBeLessThanOrEqual(Number(upperValue));
      });

      test('Home sets upper thumb to dynamic minimum (not absolute)', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        await upperThumb.click();

        // Get lower thumb value to determine expected min
        const lowerValue = await lowerThumb.getAttribute('aria-valuenow');

        await page.keyboard.press('Home');

        // Upper thumb should be at or near lower thumb value (respecting minDistance)
        const newValue = await upperThumb.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBeGreaterThanOrEqual(Number(lowerValue));
      });

      test('End sets upper thumb to absolute maximum', async ({ page }) => {
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        await upperThumb.click();

        await page.keyboard.press('End');

        await expect(upperThumb).toHaveAttribute('aria-valuenow', '100');
      });

      test('PageUp increases value by large step', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        await lowerThumb.click();

        const initialValue = await lowerThumb.getAttribute('aria-valuenow');
        await page.keyboard.press('PageUp');

        const newValue = await lowerThumb.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) + 10);
      });

      test('PageDown decreases value by large step', async ({ page }) => {
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        await upperThumb.click();

        const initialValue = await upperThumb.getAttribute('aria-valuenow');
        await page.keyboard.press('PageDown');

        const newValue = await upperThumb.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) - 10);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Collision Prevention
    // ------------------------------------------
    test.describe('APG: Collision Prevention', () => {
      test('lower thumb cannot exceed upper thumb', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        await lowerThumb.click();

        // Get upper thumb's current value
        const upperValue = await upperThumb.getAttribute('aria-valuenow');

        // Try to move lower thumb to End (dynamic max)
        await page.keyboard.press('End');

        // Verify lower thumb is at or below upper thumb
        const lowerValue = await lowerThumb.getAttribute('aria-valuenow');
        expect(Number(lowerValue)).toBeLessThanOrEqual(Number(upperValue));
      });

      test('upper thumb cannot go below lower thumb', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        await upperThumb.click();

        // Get lower thumb's current value
        const lowerValue = await lowerThumb.getAttribute('aria-valuenow');

        // Try to move upper thumb to Home (dynamic min)
        await page.keyboard.press('Home');

        // Verify upper thumb is at or above lower thumb
        const upperValue = await upperThumb.getAttribute('aria-valuenow');
        expect(Number(upperValue)).toBeGreaterThanOrEqual(Number(lowerValue));
      });

      test('thumbs cannot cross when rapidly pressing arrow keys', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const upperThumb = getSliderByLabel(page, 'Maximum Price');

        // Move lower thumb toward upper thumb
        await lowerThumb.click();
        for (let i = 0; i < 100; i++) {
          await page.keyboard.press('ArrowRight');
        }

        const lowerValue = await lowerThumb.getAttribute('aria-valuenow');
        const upperValue = await upperThumb.getAttribute('aria-valuenow');
        expect(Number(lowerValue)).toBeLessThanOrEqual(Number(upperValue));
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Focus Management
    // ------------------------------------------
    test.describe('APG: Focus Management', () => {
      test('Tab moves from lower to upper thumb', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const upperThumb = getSliderByLabel(page, 'Maximum Price');

        await lowerThumb.focus();
        await expect(lowerThumb).toBeFocused();

        await page.keyboard.press('Tab');
        await expect(upperThumb).toBeFocused();
      });

      test('Shift+Tab moves from upper to lower thumb', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const upperThumb = getSliderByLabel(page, 'Maximum Price');

        await upperThumb.focus();
        await expect(upperThumb).toBeFocused();

        await page.keyboard.press('Shift+Tab');
        await expect(lowerThumb).toBeFocused();
      });

      test('both thumbs have tabindex="0"', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const upperThumb = getSliderByLabel(page, 'Maximum Price');

        await expect(lowerThumb).toHaveAttribute('tabindex', '0');
        await expect(upperThumb).toHaveAttribute('tabindex', '0');
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: aria-valuetext Updates
    // ------------------------------------------
    test.describe('aria-valuetext Updates', () => {
      test('lower thumb aria-valuetext updates on value change', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        await lowerThumb.click();

        await page.keyboard.press('Home');
        await expect(lowerThumb).toHaveAttribute('aria-valuetext', '$0');

        await page.keyboard.press('ArrowRight');
        await expect(lowerThumb).toHaveAttribute('aria-valuetext', '$1');
      });

      test('upper thumb aria-valuetext updates on value change', async ({ page }) => {
        const upperThumb = getSliderByLabel(page, 'Maximum Price');
        await upperThumb.click();

        await page.keyboard.press('End');
        await expect(upperThumb).toHaveAttribute('aria-valuetext', '$100');

        await page.keyboard.press('ArrowLeft');
        await expect(upperThumb).toHaveAttribute('aria-valuetext', '$99');
      });
    });

    // ------------------------------------------
    // 🟢 Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        const results = await new AxeBuilder({ page })
          .include('[data-testid="basic-slider"]')
          .exclude('[aria-hidden="true"]')
          .analyze();

        expect(results.violations).toEqual([]);
      });

      test('both sliders pass axe-core', async ({ page }) => {
        const results = await new AxeBuilder({ page })
          .include('[data-testid="basic-slider"] [role="slider"]')
          .analyze();

        expect(results.violations).toEqual([]);
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Pointer Interactions
    // ------------------------------------------
    test.describe('Pointer Interactions', () => {
      test('track click moves nearest thumb', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const track = page.locator('[data-testid="basic-slider"] .apg-slider-multithumb-track');

        // Click near the start of the track (should move lower thumb)
        const trackBox = await track.boundingBox();
        if (trackBox) {
          await page.mouse.click(trackBox.x + 10, trackBox.y + trackBox.height / 2);
        }

        // Lower thumb should have moved toward the click position
        const newValue = await lowerThumb.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBeLessThan(20); // Was 20, should be lower
      });

      test('thumb can be dragged', async ({ page }) => {
        const lowerThumb = getSliderByLabel(page, 'Minimum Price');
        const thumbBox = await lowerThumb.boundingBox();

        if (thumbBox) {
          // Drag thumb to the right
          await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2);
          await page.mouse.down();
          await page.mouse.move(thumbBox.x + 100, thumbBox.y + thumbBox.height / 2);
          await page.mouse.up();
        }

        // Value should have increased
        const newValue = await lowerThumb.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBeGreaterThan(20); // Was 20
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Disabled State
    // ------------------------------------------
    test.describe('Disabled State', () => {
      test('disabled slider thumbs have tabindex="-1"', async ({ page }) => {
        const disabledSliders = page.locator('[data-testid="disabled-slider"]').getByRole('slider');

        await expect(disabledSliders.first()).toHaveAttribute('tabindex', '-1');
        await expect(disabledSliders.last()).toHaveAttribute('tabindex', '-1');
      });

      test('disabled slider thumbs have aria-disabled="true"', async ({ page }) => {
        const disabledSliders = page.locator('[data-testid="disabled-slider"]').getByRole('slider');

        await expect(disabledSliders.first()).toHaveAttribute('aria-disabled', 'true');
        await expect(disabledSliders.last()).toHaveAttribute('aria-disabled', 'true');
      });

      test('disabled slider ignores keyboard input', async ({ page }) => {
        const disabledThumb = page
          .locator('[data-testid="disabled-slider"]')
          .getByRole('slider')
          .first();

        const initialValue = await disabledThumb.getAttribute('aria-valuenow');

        // Try to click and press arrow key (disabled elements can still receive focus via click)
        await disabledThumb.click({ force: true });
        await page.keyboard.press('ArrowRight');

        // Value should not change
        await expect(disabledThumb).toHaveAttribute('aria-valuenow', initialValue!);
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Vertical Orientation
    // ------------------------------------------
    test.describe('Vertical Orientation', () => {
      test('vertical slider has aria-orientation="vertical"', async ({ page }) => {
        const verticalSliders = page.locator('[data-testid="vertical-slider"]').getByRole('slider');

        await expect(verticalSliders.first()).toHaveAttribute('aria-orientation', 'vertical');
        await expect(verticalSliders.last()).toHaveAttribute('aria-orientation', 'vertical');
      });

      test('vertical slider responds to ArrowUp/Down', async ({ page }) => {
        const verticalThumb = page
          .locator('[data-testid="vertical-slider"]')
          .getByRole('slider')
          .first();

        await verticalThumb.click();
        const initialValue = await verticalThumb.getAttribute('aria-valuenow');

        await page.keyboard.press('ArrowUp');
        const afterUp = await verticalThumb.getAttribute('aria-valuenow');
        expect(Number(afterUp)).toBe(Number(initialValue) + 1);

        await page.keyboard.press('ArrowDown');
        const afterDown = await verticalThumb.getAttribute('aria-valuenow');
        expect(Number(afterDown)).toBe(Number(initialValue));
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: minDistance
    // ------------------------------------------
    test.describe('minDistance Constraint', () => {
      test('thumbs maintain minimum distance', async ({ page }) => {
        const minDistanceSliders = page
          .locator('[data-testid="min-distance-slider"]')
          .getByRole('slider');

        const lowerThumb = minDistanceSliders.first();
        const upperThumb = minDistanceSliders.last();

        // Try to move lower thumb to End
        await lowerThumb.click();
        await page.keyboard.press('End');

        const lowerValue = Number(await lowerThumb.getAttribute('aria-valuenow'));
        const upperValue = Number(await upperThumb.getAttribute('aria-valuenow'));

        // Should maintain minDistance of 10
        expect(upperValue - lowerValue).toBeGreaterThanOrEqual(10);
      });

      test('lower thumb aria-valuemax respects minDistance', async ({ page }) => {
        const minDistanceSliders = page
          .locator('[data-testid="min-distance-slider"]')
          .getByRole('slider');

        const lowerThumb = minDistanceSliders.first();
        const upperThumb = minDistanceSliders.last();

        const upperValue = Number(await upperThumb.getAttribute('aria-valuenow'));
        const lowerMax = Number(await lowerThumb.getAttribute('aria-valuemax'));

        // Lower thumb max should be upper value - minDistance
        expect(lowerMax).toBeLessThanOrEqual(upperValue - 10);
      });

      test('upper thumb aria-valuemin respects minDistance', async ({ page }) => {
        const minDistanceSliders = page
          .locator('[data-testid="min-distance-slider"]')
          .getByRole('slider');

        const lowerThumb = minDistanceSliders.first();
        const upperThumb = minDistanceSliders.last();

        const lowerValue = Number(await lowerThumb.getAttribute('aria-valuenow'));
        const upperMin = Number(await upperThumb.getAttribute('aria-valuemin'));

        // Upper thumb min should be lower value + minDistance
        expect(upperMin).toBeGreaterThanOrEqual(lowerValue + 10);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('MultiThumbSlider - Cross-framework Consistency', () => {
  test('all frameworks render two sliders', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/slider-multithumb/${framework}/demo/`);
      await getSliders(page).first().waitFor();

      const sliders = getSliders(page);
      const count = await sliders.count();
      expect(count).toBe(2);
    }
  });

  test('all frameworks have consistent initial values', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/slider-multithumb/${framework}/demo/`);
      await getSliders(page).first().waitFor();

      // Wait for hydration
      await expect
        .poll(async () => {
          const valuenow = await getSliders(page).first().getAttribute('aria-valuenow');
          return valuenow !== null;
        })
        .toBe(true);

      const lowerThumb = getSliderByLabel(page, 'Minimum Price');
      const upperThumb = getSliderByLabel(page, 'Maximum Price');

      await expect(lowerThumb).toHaveAttribute('aria-valuenow', '20');
      await expect(upperThumb).toHaveAttribute('aria-valuenow', '80');
    }
  });

  test('all frameworks support keyboard navigation', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/slider-multithumb/${framework}/demo/`);
      await getSliders(page).first().waitFor();

      // Wait for hydration
      await expect
        .poll(async () => {
          const valuenow = await getSliders(page).first().getAttribute('aria-valuenow');
          return valuenow !== null;
        })
        .toBe(true);

      const lowerThumb = getSliderByLabel(page, 'Minimum Price');
      await lowerThumb.click();

      // Test ArrowRight
      const initialValue = await lowerThumb.getAttribute('aria-valuenow');
      await page.keyboard.press('ArrowRight');

      const newValue = await lowerThumb.getAttribute('aria-valuenow');
      expect(Number(newValue)).toBe(Number(initialValue) + 1);
    }
  });

  test('all frameworks prevent thumb crossing', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/slider-multithumb/${framework}/demo/`);
      await getSliders(page).first().waitFor();

      // Wait for hydration
      await expect
        .poll(async () => {
          const valuenow = await getSliders(page).first().getAttribute('aria-valuenow');
          return valuenow !== null;
        })
        .toBe(true);

      const lowerThumb = getSliderByLabel(page, 'Minimum Price');
      const upperThumb = getSliderByLabel(page, 'Maximum Price');

      // Try to move lower thumb beyond upper
      await lowerThumb.click();
      await page.keyboard.press('End');

      const lowerValue = Number(await lowerThumb.getAttribute('aria-valuenow'));
      const upperValue = Number(await upperThumb.getAttribute('aria-valuenow'));

      expect(lowerValue).toBeLessThanOrEqual(upperValue);
    }
  });
});

Running Tests

# Run unit tests for MultiThumbSlider
npm run test -- MultiThumbSlider

# Run E2E tests for MultiThumbSlider (all frameworks)
npm run test:e2e:pattern --pattern=slider-multithumb

# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=slider-multithumb
npm run test:e2e:vue:pattern --pattern=slider-multithumb
npm run test:e2e:svelte:pattern --pattern=slider-multithumb
npm run test:e2e:astro:pattern --pattern=slider-multithumb

Testing Tools

MultiThumbSlider.test.tsx
/* eslint-disable jsx-a11y/aria-proptypes -- Component API accepts aria-label as tuple for two thumbs */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { MultiThumbSlider } from './MultiThumbSlider';

describe('MultiThumbSlider', () => {
  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has two elements with role="slider"', () => {
      render(<MultiThumbSlider aria-label={['Minimum', 'Maximum']} />);
      const sliders = screen.getAllByRole('slider');
      expect(sliders).toHaveLength(2);
    });

    it('has aria-valuenow set on each thumb', () => {
      render(<MultiThumbSlider defaultValue={[20, 80]} aria-label={['Minimum', 'Maximum']} />);
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');
      expect(lowerThumb).toHaveAttribute('aria-valuenow', '20');
      expect(upperThumb).toHaveAttribute('aria-valuenow', '80');
    });

    it('has correct static aria-valuemin/max on lower thumb', () => {
      render(
        <MultiThumbSlider defaultValue={[20, 80]} min={0} max={100} aria-label={['Min', 'Max']} />
      );
      const [lowerThumb] = screen.getAllByRole('slider');
      expect(lowerThumb).toHaveAttribute('aria-valuemin', '0'); // absolute min
      expect(lowerThumb).toHaveAttribute('aria-valuemax', '80'); // upper thumb value
    });

    it('has correct dynamic aria-valuemin/max on upper thumb', () => {
      render(
        <MultiThumbSlider defaultValue={[20, 80]} min={0} max={100} aria-label={['Min', 'Max']} />
      );
      const [, upperThumb] = screen.getAllByRole('slider');
      expect(upperThumb).toHaveAttribute('aria-valuemin', '20'); // lower thumb value
      expect(upperThumb).toHaveAttribute('aria-valuemax', '100'); // absolute max
    });

    it('updates upper thumb aria-valuemin when lower thumb moves', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[20, 80]} aria-label={['Minimum', 'Maximum']} />);
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');

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

      expect(lowerThumb).toHaveAttribute('aria-valuenow', '21');
      expect(upperThumb).toHaveAttribute('aria-valuemin', '21');
    });

    it('updates lower thumb aria-valuemax when upper thumb moves', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[20, 80]} aria-label={['Minimum', 'Maximum']} />);
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');

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

      expect(upperThumb).toHaveAttribute('aria-valuenow', '79');
      expect(lowerThumb).toHaveAttribute('aria-valuemax', '79');
    });

    it('applies minDistance to dynamic bounds', () => {
      render(
        <MultiThumbSlider
          defaultValue={[20, 80]}
          minDistance={10}
          aria-label={['Minimum', 'Maximum']}
        />
      );
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');
      // Lower thumb max = 80 - 10 = 70
      expect(lowerThumb).toHaveAttribute('aria-valuemax', '70');
      // Upper thumb min = 20 + 10 = 30
      expect(upperThumb).toHaveAttribute('aria-valuemin', '30');
    });

    it('has aria-disabled="true" when disabled', () => {
      render(<MultiThumbSlider disabled aria-label={['Minimum', 'Maximum']} />);
      const sliders = screen.getAllByRole('slider');
      sliders.forEach((slider) => {
        expect(slider).toHaveAttribute('aria-disabled', 'true');
      });
    });

    it('does not have aria-disabled when not disabled', () => {
      render(<MultiThumbSlider aria-label={['Minimum', 'Maximum']} />);
      const sliders = screen.getAllByRole('slider');
      sliders.forEach((slider) => {
        expect(slider).not.toHaveAttribute('aria-disabled');
      });
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label tuple', () => {
      render(<MultiThumbSlider aria-label={['Minimum Price', 'Maximum Price']} />);
      expect(screen.getByRole('slider', { name: 'Minimum Price' })).toBeInTheDocument();
      expect(screen.getByRole('slider', { name: 'Maximum Price' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby tuple', () => {
      render(
        <>
          <span id="min-label">Min Value</span>
          <span id="max-label">Max Value</span>
          <MultiThumbSlider aria-labelledby={['min-label', 'max-label']} />
        </>
      );
      expect(screen.getByRole('slider', { name: 'Min Value' })).toBeInTheDocument();
      expect(screen.getByRole('slider', { name: 'Max Value' })).toBeInTheDocument();
    });

    it('has accessible name via getAriaLabel function', () => {
      render(<MultiThumbSlider getAriaLabel={(index) => (index === 0 ? 'Start' : 'End')} />);
      expect(screen.getByRole('slider', { name: 'Start' })).toBeInTheDocument();
      expect(screen.getByRole('slider', { name: 'End' })).toBeInTheDocument();
    });
  });

  // 🔴 High Priority: Keyboard Interaction
  describe('Keyboard Interaction', () => {
    it('ArrowRight increases lower thumb value', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[20, 80]} aria-label={['Min', 'Max']} />);
      const [lowerThumb] = screen.getAllByRole('slider');

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

      expect(lowerThumb).toHaveAttribute('aria-valuenow', '21');
    });

    it('ArrowRight increases upper thumb value', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[20, 80]} aria-label={['Min', 'Max']} />);
      const [, upperThumb] = screen.getAllByRole('slider');

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

      expect(upperThumb).toHaveAttribute('aria-valuenow', '81');
    });

    it('ArrowLeft decreases lower thumb value', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[20, 80]} aria-label={['Min', 'Max']} />);
      const [lowerThumb] = screen.getAllByRole('slider');

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

      expect(lowerThumb).toHaveAttribute('aria-valuenow', '19');
    });

    it('ArrowLeft decreases upper thumb value', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[20, 80]} aria-label={['Min', 'Max']} />);
      const [, upperThumb] = screen.getAllByRole('slider');

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

      expect(upperThumb).toHaveAttribute('aria-valuenow', '79');
    });

    it('lower thumb cannot exceed upper thumb with ArrowRight', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[79, 80]} aria-label={['Min', 'Max']} />);
      const [lowerThumb] = screen.getAllByRole('slider');

      await user.click(lowerThumb);
      await user.keyboard('{ArrowRight}');
      await user.keyboard('{ArrowRight}'); // Try to exceed

      expect(lowerThumb).toHaveAttribute('aria-valuenow', '80');
    });

    it('upper thumb cannot go below lower thumb with ArrowLeft', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[20, 21]} aria-label={['Min', 'Max']} />);
      const [, upperThumb] = screen.getAllByRole('slider');

      await user.click(upperThumb);
      await user.keyboard('{ArrowLeft}');
      await user.keyboard('{ArrowLeft}'); // Try to go below

      expect(upperThumb).toHaveAttribute('aria-valuenow', '20');
    });

    it('minDistance prevents collision on keyboard', async () => {
      const user = userEvent.setup();
      render(
        <MultiThumbSlider defaultValue={[45, 55]} minDistance={10} aria-label={['Min', 'Max']} />
      );
      const [lowerThumb] = screen.getAllByRole('slider');

      await user.click(lowerThumb);
      // Try to increase beyond allowed (55 - 10 = 45, already at max)
      await user.keyboard('{ArrowRight}');

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

    it('Home on lower thumb goes to absolute min', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[30, 70]} min={0} aria-label={['Min', 'Max']} />);
      const [lowerThumb] = screen.getAllByRole('slider');

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

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

    it('Home on upper thumb goes to lower thumb value (dynamic min)', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[30, 70]} min={0} aria-label={['Min', 'Max']} />);
      const [, upperThumb] = screen.getAllByRole('slider');

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

      // Should go to lower thumb value (30), not absolute min (0)
      expect(upperThumb).toHaveAttribute('aria-valuenow', '30');
    });

    it('End on lower thumb goes to upper thumb value (dynamic max)', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[30, 70]} max={100} aria-label={['Min', 'Max']} />);
      const [lowerThumb] = screen.getAllByRole('slider');

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

      // Should go to upper thumb value (70), not absolute max (100)
      expect(lowerThumb).toHaveAttribute('aria-valuenow', '70');
    });

    it('End on upper thumb goes to absolute max', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[30, 70]} max={100} aria-label={['Min', 'Max']} />);
      const [, upperThumb] = screen.getAllByRole('slider');

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

      expect(upperThumb).toHaveAttribute('aria-valuenow', '100');
    });

    it('Home/End respects minDistance', async () => {
      const user = userEvent.setup();
      render(
        <MultiThumbSlider
          defaultValue={[30, 70]}
          minDistance={10}
          min={0}
          max={100}
          aria-label={['Min', 'Max']}
        />
      );
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');

      // Lower thumb End should stop at 70 - 10 = 60
      await user.click(lowerThumb);
      await user.keyboard('{End}');
      expect(lowerThumb).toHaveAttribute('aria-valuenow', '60');

      // Upper thumb Home should stop at 60 + 10 = 70 (lower moved to 60)
      await user.click(upperThumb);
      await user.keyboard('{Home}');
      expect(upperThumb).toHaveAttribute('aria-valuenow', '70');
    });

    it('PageUp increases value by largeStep', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[20, 80]} aria-label={['Min', 'Max']} />);
      const [lowerThumb] = screen.getAllByRole('slider');

      await user.click(lowerThumb);
      await user.keyboard('{PageUp}');

      // Default largeStep = step * 10 = 10
      expect(lowerThumb).toHaveAttribute('aria-valuenow', '30');
    });

    it('PageDown decreases value by largeStep', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[20, 80]} aria-label={['Min', 'Max']} />);
      const [, upperThumb] = screen.getAllByRole('slider');

      await user.click(upperThumb);
      await user.keyboard('{PageDown}');

      expect(upperThumb).toHaveAttribute('aria-valuenow', '70');
    });

    it('PageUp respects thumb constraints', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[75, 80]} aria-label={['Min', 'Max']} />);
      const [lowerThumb] = screen.getAllByRole('slider');

      await user.click(lowerThumb);
      await user.keyboard('{PageUp}'); // Would be 85, but max is 80

      expect(lowerThumb).toHaveAttribute('aria-valuenow', '80');
    });

    it('does not change value when disabled', async () => {
      const user = userEvent.setup();
      render(<MultiThumbSlider defaultValue={[20, 80]} disabled aria-label={['Min', 'Max']} />);
      const [lowerThumb] = screen.getAllByRole('slider');

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

      expect(lowerThumb).toHaveAttribute('aria-valuenow', '20');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('both thumbs have tabindex="0"', () => {
      render(<MultiThumbSlider aria-label={['Min', 'Max']} />);
      const sliders = screen.getAllByRole('slider');
      sliders.forEach((slider) => {
        expect(slider).toHaveAttribute('tabindex', '0');
      });
    });

    it('Tab moves to lower thumb first', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <MultiThumbSlider aria-label={['Min', 'Max']} />
        </>
      );

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

      const [lowerThumb] = screen.getAllByRole('slider');
      expect(lowerThumb).toHaveFocus();
    });

    it('Tab moves from lower to upper thumb', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <MultiThumbSlider aria-label={['Min', 'Max']} />
        </>
      );

      await user.tab(); // Focus "Before" button
      await user.tab(); // Focus lower thumb
      await user.tab(); // Focus upper thumb

      const [, upperThumb] = screen.getAllByRole('slider');
      expect(upperThumb).toHaveFocus();
    });

    it('Tab order is constant regardless of thumb positions', async () => {
      const user = userEvent.setup();
      // Even if lower thumb has higher value visually, tab order follows DOM
      render(
        <>
          <button>Before</button>
          <MultiThumbSlider defaultValue={[80, 90]} aria-label={['Min', 'Max']} />
        </>
      );

      await user.tab();
      await user.tab();

      const [lowerThumb] = screen.getAllByRole('slider');
      expect(lowerThumb).toHaveFocus();
    });

    it('thumbs have tabindex="-1" when disabled', () => {
      render(<MultiThumbSlider disabled aria-label={['Min', 'Max']} />);
      const sliders = screen.getAllByRole('slider');
      sliders.forEach((slider) => {
        expect(slider).toHaveAttribute('tabindex', '-1');
      });
    });

    it('is not focusable via Tab when disabled', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <MultiThumbSlider disabled aria-label={['Min', 'Max']} />
          <button>After</button>
        </>
      );

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

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

  // 🔴 High Priority: Orientation
  describe('Orientation', () => {
    it('does not have aria-orientation for horizontal slider', () => {
      render(<MultiThumbSlider orientation="horizontal" aria-label={['Min', 'Max']} />);
      const sliders = screen.getAllByRole('slider');
      sliders.forEach((slider) => {
        expect(slider).not.toHaveAttribute('aria-orientation');
      });
    });

    it('has aria-orientation="vertical" for vertical slider', () => {
      render(<MultiThumbSlider orientation="vertical" aria-label={['Min', 'Max']} />);
      const sliders = screen.getAllByRole('slider');
      sliders.forEach((slider) => {
        expect(slider).toHaveAttribute('aria-orientation', 'vertical');
      });
    });

    it('ArrowUp increases value in vertical mode', async () => {
      const user = userEvent.setup();
      render(
        <MultiThumbSlider
          defaultValue={[20, 80]}
          orientation="vertical"
          aria-label={['Min', 'Max']}
        />
      );
      const [lowerThumb] = screen.getAllByRole('slider');

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

      expect(lowerThumb).toHaveAttribute('aria-valuenow', '21');
    });

    it('ArrowDown decreases value in vertical mode', async () => {
      const user = userEvent.setup();
      render(
        <MultiThumbSlider
          defaultValue={[20, 80]}
          orientation="vertical"
          aria-label={['Min', 'Max']}
        />
      );
      const [lowerThumb] = screen.getAllByRole('slider');

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

      expect(lowerThumb).toHaveAttribute('aria-valuenow', '19');
    });
  });

  // 🔴 High Priority: Value Text
  describe('Value Text', () => {
    it('sets aria-valuetext with format', () => {
      render(
        <MultiThumbSlider defaultValue={[20, 80]} format="${value}" aria-label={['Min', 'Max']} />
      );
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');
      expect(lowerThumb).toHaveAttribute('aria-valuetext', '$20');
      expect(upperThumb).toHaveAttribute('aria-valuetext', '$80');
    });

    it('sets aria-valuetext with getAriaValueText', () => {
      render(
        <MultiThumbSlider
          defaultValue={[20, 80]}
          getAriaValueText={(value, index) => `${index === 0 ? 'From' : 'To'} ${value}%`}
          aria-label={['Min', 'Max']}
        />
      );
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');
      expect(lowerThumb).toHaveAttribute('aria-valuetext', 'From 20%');
      expect(upperThumb).toHaveAttribute('aria-valuetext', 'To 80%');
    });

    it('updates aria-valuetext on value change', async () => {
      const user = userEvent.setup();
      render(
        <MultiThumbSlider defaultValue={[20, 80]} format="${value}" aria-label={['Min', 'Max']} />
      );
      const [lowerThumb] = screen.getAllByRole('slider');

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

      expect(lowerThumb).toHaveAttribute('aria-valuetext', '$21');
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(<MultiThumbSlider aria-label={['Minimum', 'Maximum']} />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-labelledby', async () => {
      const { container } = render(
        <>
          <span id="min">Min</span>
          <span id="max">Max</span>
          <MultiThumbSlider aria-labelledby={['min', 'max']} />
        </>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(
        <MultiThumbSlider disabled aria-label={['Minimum', 'Maximum']} />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with minDistance', async () => {
      const { container } = render(
        <MultiThumbSlider defaultValue={[20, 80]} minDistance={10} aria-label={['Min', 'Max']} />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations for vertical slider', async () => {
      const { container } = render(
        <MultiThumbSlider orientation="vertical" aria-label={['Min', 'Max']} />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Callbacks
  describe('Callbacks', () => {
    it('calls onValueChange with values array and activeIndex', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <MultiThumbSlider
          defaultValue={[20, 80]}
          onValueChange={handleChange}
          aria-label={['Min', 'Max']}
        />
      );
      const [lowerThumb] = screen.getAllByRole('slider');

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

      expect(handleChange).toHaveBeenCalledWith([21, 80], 0);
    });

    it('calls onValueChange with correct index for upper thumb', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <MultiThumbSlider
          defaultValue={[20, 80]}
          onValueChange={handleChange}
          aria-label={['Min', 'Max']}
        />
      );
      const [, upperThumb] = screen.getAllByRole('slider');

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

      expect(handleChange).toHaveBeenCalledWith([20, 79], 1);
    });

    it('does not call onValueChange when disabled', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <MultiThumbSlider
          defaultValue={[20, 80]}
          disabled
          onValueChange={handleChange}
          aria-label={['Min', 'Max']}
        />
      );
      const [lowerThumb] = screen.getAllByRole('slider');

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

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

    it('does not call onValueChange when value does not change', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <MultiThumbSlider
          defaultValue={[80, 80]}
          onValueChange={handleChange}
          aria-label={['Min', 'Max']}
        />
      );
      const [lowerThumb] = screen.getAllByRole('slider');

      await user.click(lowerThumb);
      await user.keyboard('{ArrowRight}'); // Already at max (upper thumb value)

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

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('handles decimal step values correctly', async () => {
      const user = userEvent.setup();
      render(
        <MultiThumbSlider
          defaultValue={[0.2, 0.8]}
          min={0}
          max={1}
          step={0.1}
          aria-label={['Min', 'Max']}
        />
      );
      const [lowerThumb] = screen.getAllByRole('slider');

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

      expect(lowerThumb).toHaveAttribute('aria-valuenow', '0.3');
    });

    it('handles negative min/max range', () => {
      render(
        <MultiThumbSlider defaultValue={[-30, 30]} min={-50} max={50} aria-label={['Min', 'Max']} />
      );
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');
      expect(lowerThumb).toHaveAttribute('aria-valuenow', '-30');
      expect(upperThumb).toHaveAttribute('aria-valuenow', '30');
      expect(lowerThumb).toHaveAttribute('aria-valuemin', '-50');
      expect(upperThumb).toHaveAttribute('aria-valuemax', '50');
    });

    it('normalizes invalid defaultValue (lower > upper)', () => {
      render(<MultiThumbSlider defaultValue={[80, 20]} aria-label={['Min', 'Max']} />);
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');
      // Should normalize: lower should be adjusted
      const lowerValue = Number(lowerThumb.getAttribute('aria-valuenow'));
      const upperValue = Number(upperThumb.getAttribute('aria-valuenow'));
      expect(lowerValue).toBeLessThanOrEqual(upperValue);
    });

    it('clamps defaultValue to min/max', () => {
      render(
        <MultiThumbSlider defaultValue={[-10, 150]} min={0} max={100} aria-label={['Min', 'Max']} />
      );
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');
      expect(lowerThumb).toHaveAttribute('aria-valuenow', '0');
      expect(upperThumb).toHaveAttribute('aria-valuenow', '100');
    });

    it('rounds values to step', () => {
      render(<MultiThumbSlider defaultValue={[23, 77]} step={5} aria-label={['Min', 'Max']} />);
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');
      expect(lowerThumb).toHaveAttribute('aria-valuenow', '25');
      expect(upperThumb).toHaveAttribute('aria-valuenow', '75');
    });

    it('uses default values when not provided', () => {
      render(<MultiThumbSlider aria-label={['Min', 'Max']} />);
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');
      // Default: [min, max] = [0, 100]
      expect(lowerThumb).toHaveAttribute('aria-valuenow', '0');
      expect(upperThumb).toHaveAttribute('aria-valuenow', '100');
    });
  });

  // 🟡 Medium Priority: Visual Display
  describe('Visual Display', () => {
    it('shows values when showValues is true (default)', () => {
      render(<MultiThumbSlider defaultValue={[20, 80]} aria-label={['Min', 'Max']} />);
      expect(screen.getByText('20')).toBeInTheDocument();
      expect(screen.getByText('80')).toBeInTheDocument();
    });

    it('hides values when showValues is false', () => {
      render(
        <MultiThumbSlider defaultValue={[20, 80]} showValues={false} aria-label={['Min', 'Max']} />
      );
      expect(screen.queryByText('20')).not.toBeInTheDocument();
      expect(screen.queryByText('80')).not.toBeInTheDocument();
    });

    it('displays formatted values when format provided', () => {
      render(
        <MultiThumbSlider defaultValue={[20, 80]} format="${value}" aria-label={['Min', 'Max']} />
      );
      expect(screen.getByText('$20')).toBeInTheDocument();
      expect(screen.getByText('$80')).toBeInTheDocument();
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies className to container', () => {
      render(<MultiThumbSlider aria-label={['Min', 'Max']} className="custom-slider" />);
      const container = screen.getAllByRole('slider')[0].closest('.apg-slider-multithumb');
      expect(container).toHaveClass('custom-slider');
    });

    it('sets id attribute on container', () => {
      render(<MultiThumbSlider aria-label={['Min', 'Max']} id="my-slider" />);
      const container = screen.getAllByRole('slider')[0].closest('.apg-slider-multithumb');
      expect(container).toHaveAttribute('id', 'my-slider');
    });

    it('passes through data-testid', () => {
      render(<MultiThumbSlider aria-label={['Min', 'Max']} data-testid="custom-slider" />);
      expect(screen.getByTestId('custom-slider')).toBeInTheDocument();
    });

    it('supports aria-describedby as string', () => {
      render(
        <>
          <MultiThumbSlider aria-label={['Min', 'Max']} aria-describedby="desc" />
          <p id="desc">Select a range</p>
        </>
      );
      const sliders = screen.getAllByRole('slider');
      sliders.forEach((slider) => {
        expect(slider).toHaveAttribute('aria-describedby', 'desc');
      });
    });

    it('supports aria-describedby as tuple', () => {
      render(
        <>
          <MultiThumbSlider
            aria-label={['Min', 'Max']}
            aria-describedby={['min-desc', 'max-desc']}
          />
          <p id="min-desc">Minimum value</p>
          <p id="max-desc">Maximum value</p>
        </>
      );
      const [lowerThumb, upperThumb] = screen.getAllByRole('slider');
      expect(lowerThumb).toHaveAttribute('aria-describedby', 'min-desc');
      expect(upperThumb).toHaveAttribute('aria-describedby', 'max-desc');
    });
  });

  // 🟢 Low Priority: Group Labeling
  describe('Group Labeling', () => {
    it('has role="group" on container', () => {
      render(<MultiThumbSlider aria-label={['Min', 'Max']} label="Price Range" />);
      expect(screen.getByRole('group')).toBeInTheDocument();
    });

    it('group has accessible name via label prop', () => {
      render(<MultiThumbSlider aria-label={['Min', 'Max']} label="Price Range" />);
      expect(screen.getByRole('group', { name: 'Price Range' })).toBeInTheDocument();
    });
  });
});

Resources