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.vue
<template>
  <div
    :role="label ? 'group' : undefined"
    :aria-labelledby="label ? groupLabelId : undefined"
    :class="[
      'apg-slider-multithumb',
      isVertical && 'apg-slider-multithumb--vertical',
      disabled && 'apg-slider-multithumb--disabled',
      $attrs.class,
    ]"
    :id="$attrs.id as string | undefined"
    :data-testid="$attrs['data-testid'] as string | undefined"
  >
    <span v-if="label" :id="groupLabelId" class="apg-slider-multithumb-label">
      {{ label }}
    </span>
    <div
      ref="trackRef"
      class="apg-slider-multithumb-track"
      :style="{
        '--slider-lower': `${lowerPercent}%`,
        '--slider-upper': `${upperPercent}%`,
      }"
      @click="handleTrackClick"
    >
      <div class="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="getLowerBoundsMax()"
        :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)"
        class="apg-slider-multithumb-thumb apg-slider-multithumb-thumb--lower"
        :style="isVertical ? { bottom: `${lowerPercent}%` } : { left: `${lowerPercent}%` }"
        @keydown="handleKeyDown(0, $event)"
        @pointerdown="handleThumbPointerDown(0, $event)"
        @pointermove="handleThumbPointerMove(0, $event)"
        @pointerup="handleThumbPointerUp(0, $event)"
      >
        <span class="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="getUpperBoundsMin()"
        :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)"
        class="apg-slider-multithumb-thumb apg-slider-multithumb-thumb--upper"
        :style="isVertical ? { bottom: `${upperPercent}%` } : { left: `${upperPercent}%` }"
        @keydown="handleKeyDown(1, $event)"
        @pointerdown="handleThumbPointerDown(1, $event)"
        @pointermove="handleThumbPointerMove(1, $event)"
        @pointerup="handleThumbPointerUp(1, $event)"
      >
        <span class="apg-slider-multithumb-tooltip" aria-hidden="true">
          {{ getThumbAriaLabel(1) }}
        </span>
      </div>
    </div>
    <div v-if="showValues" class="apg-slider-multithumb-values" aria-hidden="true">
      <span class="apg-slider-multithumb-value apg-slider-multithumb-value--lower">
        {{ getDisplayText(0) }}
      </span>
      <span class="apg-slider-multithumb-value-separator"> - </span>
      <span class="apg-slider-multithumb-value apg-slider-multithumb-value--upper">
        {{ getDisplayText(1) }}
      </span>
    </div>
  </div>
</template>

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

defineOptions({
  inheritAttrs: false,
});

export interface MultiThumbSliderProps {
  /** 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;
  /** Visible label for the group */
  label?: string;
  /** aria-label per thumb */
  ariaLabel?: [string, string];
  /** aria-labelledby per thumb */
  ariaLabelledby?: [string, string];
  /** aria-describedby per thumb (tuple or single for both) */
  ariaDescribedby?: string | [string, string];
  /** Function to get aria-valuetext per thumb */
  getAriaValueText?: (value: number, index: number) => string;
  /** Function to get aria-label per thumb */
  getAriaLabel?: (index: number) => string;
}

const props = withDefaults(defineProps<MultiThumbSliderProps>(), {
  defaultValue: undefined,
  min: 0,
  max: 100,
  step: 1,
  largeStep: undefined,
  minDistance: 0,
  orientation: 'horizontal',
  disabled: false,
  showValues: true,
  format: undefined,
  label: undefined,
  ariaLabel: undefined,
  ariaLabelledby: undefined,
  ariaDescribedby: undefined,
  getAriaValueText: undefined,
  getAriaLabel: undefined,
});

const emit = defineEmits<{
  valueChange: [values: [number, number], activeThumbIndex: number];
  valueCommit: [values: [number, number]];
}>();

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

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

const getPercent = (val: number, minVal: number, maxVal: number): number => {
  if (maxVal === minVal) return 0;
  return ((val - minVal) / (maxVal - minVal)) * 100;
};

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

// Get dynamic bounds for a thumb
const getThumbBounds = (
  index: number,
  currentValues: [number, number],
  minVal: number,
  maxVal: number,
  minDist: number
): { min: number; max: number } => {
  const effectiveMinDistance = Math.min(minDist, maxVal - minVal);
  if (index === 0) {
    return { min: minVal, max: currentValues[1] - effectiveMinDistance };
  } else {
    return { min: currentValues[0] + effectiveMinDistance, max: maxVal };
  }
};

// Normalize values to ensure they are valid
const normalizeValues = (
  inputValues: [number, number],
  minVal: number,
  maxVal: number,
  stepVal: number,
  minDist: number
): [number, number] => {
  let [lower, upper] = inputValues;
  const effectiveMinDistance = Math.min(minDist, maxVal - minVal);

  lower = roundToStep(lower, stepVal, minVal);
  upper = roundToStep(upper, stepVal, minVal);

  lower = clamp(lower, minVal, maxVal - effectiveMinDistance);
  upper = clamp(upper, minVal + effectiveMinDistance, maxVal);

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

  return [lower, upper];
};

// Refs
const lowerThumbRef = ref<HTMLDivElement | null>(null);
const upperThumbRef = ref<HTMLDivElement | null>(null);
const trackRef = ref<HTMLDivElement | null>(null);
const groupLabelId = useId();
const activeThumbIndex = ref<number | null>(null);

// State
const initialValues = normalizeValues(
  props.defaultValue ?? [props.min, props.max],
  props.min,
  props.max,
  props.step,
  props.minDistance
);
const values = ref<[number, number]>(initialValues);

// Computed
const isVertical = computed(() => props.orientation === 'vertical');
const effectiveLargeStep = computed(() => props.largeStep ?? props.step * 10);
const lowerPercent = computed(() => getPercent(values.value[0], props.min, props.max));
const upperPercent = computed(() => getPercent(values.value[1], props.min, props.max));

// Bounds helpers
const getLowerBoundsMax = () =>
  getThumbBounds(0, values.value, props.min, props.max, props.minDistance).max;
const getUpperBoundsMin = () =>
  getThumbBounds(1, values.value, props.min, props.max, props.minDistance).min;

// Update values and emit
const updateValues = (newValues: [number, number], activeIndex: number) => {
  values.value = newValues;
  emit('valueChange', newValues, activeIndex);
};

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

  if (clamped === values.value[index]) return;

  const newValues: [number, number] = [...values.value];
  newValues[index] = clamped;
  updateValues(newValues, index);
};

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

  const rect = track.getBoundingClientRect();

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

  let pct: number;

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

  const rawValue = props.min + pct * (props.max - props.min);
  return roundToStep(rawValue, props.step, props.min);
};

// Keyboard handler
const handleKeyDown = (index: number, event: KeyboardEvent) => {
  if (props.disabled) return;

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

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

  event.preventDefault();
  updateThumbValue(index, newValue);
};

// Pointer handlers
const getThumbRef = (index: number) => (index === 0 ? lowerThumbRef : upperThumbRef);

const handleThumbPointerDown = (index: number, event: PointerEvent) => {
  if (props.disabled) return;

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

  if (typeof thumb.setPointerCapture === 'function') {
    thumb.setPointerCapture(event.pointerId);
  }
  activeThumbIndex.value = index;
  thumb.focus();
};

const handleThumbPointerMove = (index: number, event: PointerEvent) => {
  const thumb = getThumbRef(index).value;
  if (!thumb) return;

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

  if (!hasCapture) return;

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

const handleThumbPointerUp = (index: number, event: PointerEvent) => {
  const thumb = getThumbRef(index).value;
  if (thumb && typeof thumb.releasePointerCapture === 'function') {
    try {
      thumb.releasePointerCapture(event.pointerId);
    } catch {
      // Ignore
    }
  }
  activeThumbIndex.value = null;
  emit('valueCommit', values.value);
};

// Track click handler
const handleTrackClick = (event: MouseEvent) => {
  if (props.disabled) return;
  if (event.target === lowerThumbRef.value || event.target === upperThumbRef.value) return;

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

  const distToLower = Math.abs(clickValue - values.value[0]);
  const distToUpper = Math.abs(clickValue - values.value[1]);
  const targetIndex = distToLower <= distToUpper ? 0 : 1;

  updateThumbValue(targetIndex, clickValue);
  getThumbRef(targetIndex).value?.focus();
};

// ARIA helpers
const getThumbAriaLabel = (index: number): string | undefined => {
  if (props.ariaLabel) {
    return props.ariaLabel[index];
  }
  if (props.getAriaLabel) {
    return props.getAriaLabel(index);
  }
  return undefined;
};

const getThumbAriaLabelledby = (index: number): string | undefined => {
  if (props.ariaLabelledby) {
    return props.ariaLabelledby[index];
  }
  return undefined;
};

const getThumbAriaDescribedby = (index: number): string | undefined => {
  if (!props.ariaDescribedby) return undefined;
  if (Array.isArray(props.ariaDescribedby)) {
    return props.ariaDescribedby[index];
  }
  return props.ariaDescribedby;
};

const getThumbAriaValueText = (index: number): string | undefined => {
  const value = values.value[index];
  if (props.getAriaValueText) {
    return props.getAriaValueText(value, index);
  }
  if (props.format) {
    return formatValue(value, props.format, props.min, props.max);
  }
  return undefined;
};

const getDisplayText = (index: number): string => {
  return formatValue(values.value[index], props.format, props.min, props.max);
};
</script>

Usage

Example
<script setup>
import MultiThumbSlider from './MultiThumbSlider.vue';

function handleValueChange(values, activeIndex) {
  console.log('Changed:', values, 'Active thumb:', activeIndex);
}

function handleValueCommit(values) {
  console.log('Committed:', values);
}
</script>

<template>
  <!-- Basic usage with visible label and ariaLabel tuple -->
  <MultiThumbSlider
    :default-value="[20, 80]"
    label="Price Range"
    :aria-label="['Minimum Price', 'Maximum Price']"
  />

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

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

  <!-- With events -->
  <MultiThumbSlider
    :default-value="[20, 80]"
    label="Range"
    :aria-label="['Lower', 'Upper']"
    @value-change="handleValueChange"
    @value-commit="handleValueCommit"
  />
</template>

API

Props

Prop Type Default Description
defaultValue [number, number] [min, max] Initial values for the two thumbs
min number 0 Minimum value
max number 100 Maximum value
step number 1 Step increment
minDistance number 0 Minimum distance between thumbs
disabled boolean false Whether the slider is disabled
label string - Visible label for the slider group
format string - Format pattern for display
ariaLabel [string, string] - Accessible labels for each thumb
ariaLabelledby [string, string] - IDs of external label elements

Events

Event Payload Description
value-change (values, activeIndex) Emitted when any value changes
value-commit (values) Emitted when interaction ends

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

Resources