APG Patterns
English
English

Slider (Multi-Thumb)

指定された範囲内で範囲を選択するための2つのつまみを持つスライダー。

デモ

価格範囲
温度範囲
予算範囲(minDistance付き)
無効化状態

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
slider 下限つまみ要素 範囲の下限を選択するためのスライダーとして要素を識別します。
slider 上限つまみ要素 範囲の上限を選択するためのスライダーとして要素を識別します。
group コンテナ要素 2つのスライダーをグループ化し、共通のラベルに関連付けます。

各つまみは独自のARIA属性を持つ独立した slider 要素です。 group ロールが2つのスライダー間のセマンティックな関係を確立します。

WAI-ARIA プロパティ

aria-valuenow (必須)

各つまみの現在の数値を示します。ユーザーが値を変更すると動的に更新されます。

Number
必須 はい
範囲 aria-valueminaria-valuemax の間である必要があります

aria-valuemin (必須)

動的な境界: APG仕様に従い、一方のスライダーの範囲が他方の値に依存する場合、これらの属性は動的に更新する必要があります:

つまみ aria-valuemin aria-valuemax
下限つまみ 静的(絶対最小値) 動的(上限値 - minDistance)
上限つまみ 動的(下限値 + minDistance) 静的(絶対最大値)

このアプローチにより、支援技術ユーザーに各つまみの実際の許容範囲を正しく伝えることができ、HomeキーとEndキーの動作の予測可能性が向上します。

aria-valuemax (必須)

aria-valuetext

現在の値の人間が読めるテキスト代替を提供します。数値だけでは十分な意味を伝えられない場合に使用します。

String
必須 いいえ(値にコンテキストが必要な場合は推奨)
"$20", "$80", "20% - 80%"

aria-orientation

スライダーの向きを指定します。垂直スライダーの場合のみ "vertical" に設定します。水平の場合は省略します(デフォルト)。

"horizontal" | "vertical"
必須 いいえ
デフォルト horizontal(暗黙的)

aria-disabled

スライダーが無効でインタラクティブではないことを示します。

true | undefined
必須 いいえ

キーボードサポート

キー アクション
Tab つまみ間でフォーカスを移動(下限から上限へ)
Shift + Tab つまみ間でフォーカスを移動(上限から下限へ)
Right Arrow 値を1ステップ増加させる
Up Arrow 値を1ステップ増加させる
Left Arrow 値を1ステップ減少させる
Down Arrow 値を1ステップ減少させる
Home つまみを許容最小値に設定(上限つまみは動的)
End つまみを許容最大値に設定(下限つまみは動的)
Page Up 値を大きいステップで増加させる(デフォルト: step * 10)
Page Down 値を大きいステップで減少させる(デフォルト: step * 10)

衝突防止

マルチサムスライダーは、つまみが互いに交差しないようにします:

  • 下限つまみ - (上限値 - minDistance)を超えられない
  • 上限つまみ - (下限値 + minDistance)を下回れない
  • minDistance - つまみ間の設定可能な最小ギャップ(デフォルト: 0)

アクセシブルな名前

マルチサムスライダーでは、2つのつまみを区別するために慎重なラベル付けが必要です。この実装は以下のアプローチをサポートしています:

  • 表示されるグループラベル - label プロパティを使用してスライダーグループの表示ラベルを提供
  • aria-label (タプル)- 各つまみに個別のラベルを提供(例: ["最小価格", "最大価格"])
  • aria-labelledby (タプル)- 外部要素を各つまみのラベルとして参照
  • getAriaLabel 関数 - つまみのインデックスに基づいた動的なラベル生成

フォーカス管理

この実装でのフォーカス動作:

  • タブ順序 - 両方のつまみがタブ順序に含まれる(tabindex="0")
  • 固定順序 - 値に関係なく、下限つまみが常にタブ順序で先に来る
  • トラッククリック - トラックをクリックすると最も近いつまみが移動してフォーカスされる

ポインター操作

この実装はマウスとタッチ操作をサポートしています:

  • トラックをクリック: 最も近いつまみをクリック位置に移動
  • つまみをドラッグ: ドラッグ中の連続的な調整が可能
  • ポインターキャプチャ: ポインターがスライダーの外に出てもインタラクションを維持

ビジュアルデザイン

この実装は、アクセシブルなビジュアルデザインのためのWCAGガイドラインに従っています:

  • フォーカスインジケーター: 各つまみ要素に可視のフォーカスリング
  • 範囲インジケーター: つまみ間の選択範囲の視覚的表現
  • ホバー状態: ホバー時の視覚的フィードバック
  • 無効状態: スライダーが無効な時の明確な視覚的表示
  • 強制カラーモード: Windowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用

参考資料

ソースコード

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

使い方

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

function App() {
  return (
    <div>
      {/* 基本的な使用方法(可視ラベルとaria-labelタプル) */}
      <MultiThumbSlider
        defaultValue={[20, 80]}
        label="価格範囲"
        aria-label={['最小価格', '最大価格']}
      />

      {/* 表示とaria-valuetextのフォーマット */}
      <MultiThumbSlider
        defaultValue={[25, 75]}
        label="温度"
        format="{value}°C"
        aria-label={['最小温度', '最大温度']}
      />

      {/* つまみが近づきすぎないようにminDistanceを設定 */}
      <MultiThumbSlider
        defaultValue={[30, 70]}
        minDistance={10}
        label="予算"
        format="${value}"
        aria-label={['最小予算', '最大予算']}
      />

      {/* カスタム範囲とステップ */}
      <MultiThumbSlider
        defaultValue={[200, 800]}
        min={0}
        max={1000}
        step={50}
        label="価格フィルター"
        format="${value}"
        aria-label={['最小価格', '最大価格']}
      />

      {/* コールバック付き */}
      <MultiThumbSlider
        defaultValue={[20, 80]}
        label="範囲"
        aria-label={['下限', '上限']}
        onValueChange={(values, activeIndex) => {
          console.log('変更:', values, 'アクティブなつまみ:', activeIndex);
        }}
        onValueCommit={(values) => {
          console.log('確定:', values);
        }}
      />

      {/* 外部ラベルにaria-labelledbyを使用 */}
      <span id="min-label">最小値</span>
      <span id="max-label">最大値</span>
      <MultiThumbSlider
        defaultValue={[20, 80]}
        aria-labelledby={['min-label', 'max-label']}
      />
    </div>
  );
}

API

プロパティ デフォルト 説明
defaultValue [number, number] [min, max] 2つのつまみの初期値 [下限, 上限]
min number 0 最小値(絶対値)
max number 100 最大値(絶対値)
step number 1 キーボードナビゲーションのステップ増分
largeStep number step × 10 PageUp/PageDownの大きなステップ
minDistance number 0 2つのつまみ間の最小距離
orientation 'horizontal' | 'vertical' 'horizontal' スライダーの向き
disabled boolean false スライダーが無効化されているかどうか
showValues boolean true 値のテキストを表示するかどうか
label string - スライダーグループの可視ラベル
format string - 表示とaria-valuetextのフォーマットパターン(例:"{value}%"、"${value}")
aria-label [string, string] - 各つまみのアクセシブルなラベル [下限, 上限]
aria-labelledby [string, string] - 外部ラベル要素のID [下限, 上限]
getAriaValueText (value, index) => string - aria-valuetextを動的に生成する関数
getAriaLabel (index) => string - aria-labelを動的に生成する関数
onValueChange (values, activeIndex) => void - いずれかの値が変更されたときのコールバック
onValueCommit (values) => void - 操作が終了したときのコールバック(ドラッグ終了、キーアップ)

注意: 各つまみにアクセシブルな名前を提供するため、aria-labelaria-labelledby、またはgetAriaLabelのいずれかが必要です。

テスト

テストは、ARIA属性、キーボードインタラクション、衝突防止、マルチサムスライダーのアクセシビリティ要件のAPG準拠を検証します。

テストカテゴリ

高優先度: ARIA 構造

テスト 説明
two slider elements コンテナにrole="slider"を持つ要素がちょうど2つある
role="group" スライダーがaria-labelledbyを持つグループに含まれている
aria-valuenow 両方のつまみに正しい初期値が設定されている
static aria-valuemin 下限つまみに静的な最小値(絶対最小値)がある
static aria-valuemax 上限つまみに静的な最大値(絶対最大値)がある
dynamic aria-valuemax 下限つまみの最大値が上限つまみの値に依存する
dynamic aria-valuemin 上限つまみの最小値が下限つまみの値に依存する

高優先度: 動的な境界の更新

テスト 説明
lower -> upper bound 下限つまみを動かすと上限つまみのaria-valueminが更新される
upper -> lower bound 上限つまみを動かすと下限つまみのaria-valuemaxが更新される

高優先度: キーボードインタラクション

テスト 説明
Arrow Right/Up 値を1ステップ増加
Arrow Left/Down 値を1ステップ減少
Home (lower) 下限つまみを絶対最小値に設定
End (lower) 下限つまみを動的最大値に設定(上限 - minDistance)
Home (upper) 上限つまみを動的最小値に設定(下限 + minDistance)
End (upper) 上限つまみを絶対最大値に設定
Page Up/Down 値を大きいステップで増加/減少

高優先度: 衝突防止

テスト 説明
lower cannot exceed upper 下限つまみが(上限 - minDistance)で停止する
upper cannot go below lower 上限つまみが(下限 + minDistance)で停止する
rapid key presses 矢印キーを連打してもつまみが交差しない

高優先度: フォーカス管理

テスト 説明
Tab order Tabで下限から上限つまみに移動
Shift+Tab order Shift+Tabで上限から下限つまみに移動
tabindex="0" 両方のつまみにtabindex="0"がある(常にタブ順序に含まれる)

中優先度: aria-valuetextの更新

テスト 説明
lower thumb update 下限つまみの値が変更されるとaria-valuetextが更新される
upper thumb update 上限つまみの値が変更されるとaria-valuetextが更新される

中優先度: アクセシビリティ

テスト 説明
axe violations (container) コンテナにアクセシビリティ違反がない
axe violations (sliders) 各スライダー要素にアクセシビリティ違反がない

低優先度: フレームワーク間の一貫性

テスト 説明
render two sliders すべてのフレームワークがちょうど2つのスライダー要素をレンダリング
consistent initial values すべてのフレームワークで同一の初期aria-valuenow値
keyboard navigation すべてのフレームワークで同一のキーボードナビゲーション
collision prevention すべてのフレームワークでつまみの交差を防止

テストコード例

以下は実際のE2Eテストファイル(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);
    }
  });
});

テストの実行

# 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

テストツール

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

リソース