Slider (Multi-Thumb)
指定された範囲内で範囲を選択するための2つのつまみを持つスライダー。
デモ
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
slider | 下限つまみ要素 | 範囲の下限を選択するためのスライダーとして要素を識別します。 |
slider | 上限つまみ要素 | 範囲の上限を選択するためのスライダーとして要素を識別します。 |
group | コンテナ要素 | 2つのスライダーをグループ化し、共通のラベルに関連付けます。 |
各つまみは独自のARIA属性を持つ独立した slider 要素です。
group ロールが2つのスライダー間のセマンティックな関係を確立します。
WAI-ARIA プロパティ
aria-valuenow (必須)
各つまみの現在の数値を示します。ユーザーが値を変更すると動的に更新されます。
| 型 | Number |
| 必須 | はい |
| 範囲 | aria-valuemin と aria-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ハイコントラストモードでのアクセシビリティのためにシステムカラーを使用
参考資料
ソースコード
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>
);
}; 使い方
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-label、aria-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)です。
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 テストツール
- Vitest (opens in new tab) - ユニットテストランナー
- Testing Library (opens in new tab) - フレームワーク別テストユーティリティ
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core (opens in new tab) - アクセシビリティテストエンジン
/* 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();
});
});
}); リソース
- WAI-ARIA APG: Slider (Multi-Thumb) パターン (opens in new tab)
- WAI-ARIA APG: Slider パターン (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist