Slider
指定された範囲内から値を選択する入力。
🤖 AI 実装ガイドデモ
Native HTML
Use Native HTML First
Before using this custom component, consider using native <input type="range">
elements.
They provide built-in keyboard support, work without JavaScript, and have native accessibility support.
<label for="volume">Volume</label>
<input type="range" id="volume" min="0" max="100" value="50"> Use custom implementations only when you need custom styling that native elements cannot provide, or when you require specific visual feedback during interactions.
| Use Case | Native HTML | Custom Implementation |
|---|---|---|
| Basic value selection | Recommended | Not needed |
| Keyboard support | Built-in | Manual implementation |
| JavaScript disabled support | Works natively | Requires fallback |
| Form integration | Built-in | Manual implementation |
| Custom styling | Limited (pseudo-elements) | Full control |
| Consistent cross-browser appearance | Varies significantly | Consistent |
| Vertical orientation | Limited browser support | Full control |
Note: Native <input type="range"> styling is notoriously inconsistent across browsers.
Styling requires vendor-specific pseudo-elements (::-webkit-slider-thumb,
::-moz-range-thumb, etc.) which can be complex to maintain.
アクセシビリティ
WAI-ARIA ロール
| ロール | 要素 | 説明 |
|---|---|---|
slider | つまみ要素 | 範囲内から値を選択できるスライダーとして要素を識別します。 |
slider ロールは、トラックに沿ってつまみを移動させることでユーザーが値を選択できるインタラクティブなコントロールに使用されます。meter ロールとは異なり、スライダーはインタラクティブでキーボードフォーカスを受け取ります。
WAI-ARIA ステート/プロパティ
aria-valuenow (必須)
スライダーの現在の数値を示します。ユーザーが値を変更すると動的に更新されます。
| 型 | 数値 |
| 必須 | はい |
| 範囲 | aria-valuemin と aria-valuemax の間である必要があります
|
aria-valuemin (必須)
スライダーの最小許容値を指定します。
| 型 | 数値 |
| 必須 | はい |
| デフォルト | 0 |
aria-valuemax (必須)
スライダーの最大許容値を指定します。
| 型 | 数値 |
| 必須 | はい |
| デフォルト | 100 |
aria-valuetext
現在の値に対して人間が読めるテキストの代替を提供します。数値だけでは十分な意味が伝わらない場合に使用します。
| 型 | 文字列 |
| 必須 | いいえ(値に文脈が必要な場合は推奨) |
| 例 | "50%", "Medium", "3 of 5 stars" |
aria-orientation
スライダーの向きを指定します。垂直スライダーの場合のみ "vertical" に設定し、水平の場合(デフォルト)は省略します。
| 型 | "horizontal" | "vertical" |
| 必須 | いいえ |
| デフォルト | horizontal(暗黙的) |
aria-disabled
スライダーが無効化されており、インタラクティブではないことを示します。
| 型 | 真偽値 |
| 必須 | いいえ |
キーボードサポート
| キー | アクション |
|---|---|
| Right Arrow | 値を1ステップ増加させます |
| Up Arrow | 値を1ステップ増加させます |
| Left Arrow | 値を1ステップ減少させます |
| Down Arrow | 値を1ステップ減少させます |
| Home | スライダーを最小値に設定します |
| End | スライダーを最大値に設定します |
| Page Up | 値を大きなステップで増加させます(デフォルト: step * 10) |
| Page Down | 値を大きなステップで減少させます(デフォルト: step * 10) |
アクセシブルな名前付け
スライダーにはアクセシブルな名前が必要です。これは以下の方法で提供できます:
- 可視ラベル -
labelプロパティを使用して可視ラベルを表示します -
aria-label- スライダーに不可視のラベルを提供します -
aria-labelledby- 外部要素をラベルとして参照します
ポインター操作
この実装はマウスとタッチ操作をサポートしています:
- トラックをクリック - クリックした位置にすぐつまみを移動させます
- つまみをドラッグ - ドラッグ中に連続的な調整が可能です
- ポインターキャプチャ - ポインターがスライダーの外に移動しても操作を維持します
視覚デザイン
この実装は、アクセシブルな視覚デザインのためのWCAGガイドラインに従っています:
- フォーカスインジケーター - つまみ要素に可視のフォーカスリング
- 視覚的な塗りつぶし - 現在の値を比例的に表現します
- ホバー状態 - ホバー時の視覚的なフィードバック
- 無効化状態 - スライダーが無効化されているときの明確な視覚的表示
- 強制カラーモード - Windowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用します
参考資料
ソースコード
import { clsx } from 'clsx';
import { useCallback, useId, useRef, useState } from 'react';
// Label: one of these required (exclusive)
type LabelProps =
| { label: string; 'aria-label'?: never; 'aria-labelledby'?: never }
| { label?: never; 'aria-label': string; 'aria-labelledby'?: never }
| { label?: never; 'aria-label'?: never; 'aria-labelledby': string };
// ValueText: exclusive with format
type ValueTextProps =
| { valueText: string; format?: never }
| { valueText?: never; format?: string }
| { valueText?: never; format?: never };
type SliderBaseProps = {
defaultValue?: number;
min?: number;
max?: number;
step?: number;
largeStep?: number;
orientation?: 'horizontal' | 'vertical';
disabled?: boolean;
showValue?: boolean;
onValueChange?: (value: number) => void;
className?: string;
id?: string;
'aria-describedby'?: string;
'data-testid'?: string;
};
export type SliderProps = SliderBaseProps & LabelProps & ValueTextProps;
// Clamp value to min/max range
const clamp = (value: number, min: number, max: number): number => {
return Math.min(max, Math.max(min, value));
};
// Round value to nearest step
const roundToStep = (value: number, step: number, min: number): number => {
const steps = Math.round((value - min) / step);
const result = min + steps * step;
// Fix floating point precision issues
const decimalPlaces = (step.toString().split('.')[1] || '').length;
return Number(result.toFixed(decimalPlaces));
};
// Calculate percentage for visual position
const getPercent = (value: number, min: number, max: number): number => {
if (max === min) return 0;
return ((value - min) / (max - min)) * 100;
};
// Format value helper
const formatValueText = (
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));
};
export const Slider: React.FC<SliderProps> = ({
defaultValue,
min = 0,
max = 100,
step = 1,
largeStep,
orientation = 'horizontal',
disabled = false,
showValue = true,
onValueChange,
label,
valueText,
format,
className,
id,
'aria-describedby': ariaDescribedby,
'data-testid': dataTestId,
...rest
}) => {
// Calculate initial value: defaultValue clamped and rounded to step
const initialValue = clamp(roundToStep(defaultValue ?? min, step, min), min, max);
const [value, setValue] = useState(initialValue);
const thumbRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const labelId = useId();
const isVertical = orientation === 'vertical';
const effectiveLargeStep = largeStep ?? step * 10;
// Update value and call callback
const updateValue = useCallback(
(newValue: number) => {
const clampedValue = clamp(roundToStep(newValue, step, min), min, max);
if (clampedValue !== value) {
setValue(clampedValue);
onValueChange?.(clampedValue);
}
},
[value, step, min, max, onValueChange]
);
// Calculate value from pointer position
const getValueFromPointer = useCallback(
(clientX: number, clientY: number) => {
const track = trackRef.current;
if (!track) return value;
const rect = track.getBoundingClientRect();
// Guard against zero-size track (e.g., in jsdom tests)
if (rect.width === 0 && rect.height === 0) {
return value;
}
let percent: number;
if (isVertical) {
// Vertical: top = max, bottom = min
if (rect.height === 0) return value;
percent = 1 - (clientY - rect.top) / rect.height;
} else {
// Horizontal: left = min, right = max
if (rect.width === 0) return value;
percent = (clientX - rect.left) / rect.width;
}
const rawValue = min + percent * (max - min);
return clamp(roundToStep(rawValue, step, min), min, max);
},
[isVertical, min, max, step, value]
);
// Keyboard handler
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (disabled) return;
let newValue = value;
switch (event.key) {
case 'ArrowRight':
case 'ArrowUp':
newValue = value + step;
break;
case 'ArrowLeft':
case 'ArrowDown':
newValue = value - step;
break;
case 'Home':
newValue = min;
break;
case 'End':
newValue = max;
break;
case 'PageUp':
newValue = value + effectiveLargeStep;
break;
case 'PageDown':
newValue = value - effectiveLargeStep;
break;
default:
return; // Don't prevent default for other keys
}
event.preventDefault();
updateValue(newValue);
},
[value, step, min, max, effectiveLargeStep, disabled, updateValue]
);
// Track whether we're dragging (for environments without pointer capture support)
const isDraggingRef = useRef(false);
// Pointer handlers for drag
const handlePointerDown = useCallback(
(event: React.PointerEvent) => {
if (disabled) return;
event.preventDefault();
const thumb = thumbRef.current;
if (!thumb) return;
// Use pointer capture if available
if (typeof thumb.setPointerCapture === 'function') {
thumb.setPointerCapture(event.pointerId);
}
isDraggingRef.current = true;
thumb.focus();
const newValue = getValueFromPointer(event.clientX, event.clientY);
updateValue(newValue);
},
[disabled, getValueFromPointer, updateValue]
);
const handlePointerMove = useCallback(
(event: React.PointerEvent) => {
const thumb = thumbRef.current;
if (!thumb) return;
// Check pointer capture or fallback to dragging state
const hasCapture =
typeof thumb.hasPointerCapture === 'function'
? thumb.hasPointerCapture(event.pointerId)
: isDraggingRef.current;
if (!hasCapture) return;
const newValue = getValueFromPointer(event.clientX, event.clientY);
updateValue(newValue);
},
[getValueFromPointer, updateValue]
);
const handlePointerUp = useCallback((event: React.PointerEvent) => {
const thumb = thumbRef.current;
if (thumb && typeof thumb.releasePointerCapture === 'function') {
try {
thumb.releasePointerCapture(event.pointerId);
} catch {
// Ignore if pointer capture was not set
}
}
isDraggingRef.current = false;
}, []);
// Track click handler
const handleTrackClick = useCallback(
(event: React.MouseEvent) => {
if (disabled) return;
// Ignore if already handled by thumb
if (event.target === thumbRef.current) return;
const newValue = getValueFromPointer(event.clientX, event.clientY);
updateValue(newValue);
thumbRef.current?.focus();
},
[disabled, getValueFromPointer, updateValue]
);
const percent = getPercent(value, min, max);
// Determine aria-valuetext
const ariaValueText =
valueText ?? (format ? formatValueText(value, format, min, max) : undefined);
// Determine display text
const displayText = valueText ? valueText : formatValueText(value, format, min, max);
// Determine aria-labelledby
const ariaLabelledby = rest['aria-labelledby'] ?? (label ? labelId : undefined);
return (
<div
className={clsx(
'apg-slider',
isVertical && 'apg-slider--vertical',
disabled && 'apg-slider--disabled',
className
)}
>
{label && (
<span id={labelId} className="apg-slider-label">
{label}
</span>
)}
<div
ref={trackRef}
className="apg-slider-track"
style={{ '--slider-position': `${percent}%` }}
onClick={handleTrackClick}
>
<div className="apg-slider-fill" aria-hidden="true" />
<div
ref={thumbRef}
role="slider"
id={id}
tabIndex={disabled ? -1 : 0}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={ariaValueText}
aria-label={rest['aria-label']}
aria-labelledby={ariaLabelledby}
aria-orientation={isVertical ? 'vertical' : undefined}
aria-disabled={disabled ? true : undefined}
aria-describedby={ariaDescribedby}
data-testid={dataTestId}
className="apg-slider-thumb"
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
/>
</div>
{showValue && (
<span className="apg-slider-value" aria-hidden="true">
{displayText}
</span>
)}
</div>
);
}; 使い方
import { Slider } from './Slider';
function App() {
return (
<div>
{/* Basic usage with aria-label */}
<Slider defaultValue={50} aria-label="Volume" />
{/* With visible label */}
<Slider defaultValue={50} label="Volume" />
{/* With format for display and aria-valuetext */}
<Slider
defaultValue={75}
label="Progress"
format="{value}%"
/>
{/* Custom range with step */}
<Slider
defaultValue={3}
min={1}
max={5}
step={1}
label="Rating"
format="{value} of {max}"
/>
{/* Vertical slider */}
<Slider
defaultValue={50}
label="Volume"
orientation="vertical"
/>
{/* With callback */}
<Slider
defaultValue={50}
label="Value"
onValueChange={(value) => console.log(value)}
/>
</div>
);
} API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
defaultValue | number | min | スライダーの初期値 |
min | number | 0 | 最小値 |
max | number | 100 | 最大値 |
step | number | 1 | キーボードナビゲーションのステップ増分 |
largeStep | number | step * 10 | PageUp/PageDownの大きなステップ |
orientation | 'horizontal' | 'vertical' | 'horizontal' | スライダーの向き |
disabled | boolean | false | スライダーが無効化されているかどうか |
showValue | boolean | true | 値のテキストを表示するかどうか |
label | string | - | 表示されるラベル(aria-labelledbyとしても使用) |
valueText | string | - | aria-valuetextの人間が読める値 |
format | string | - | 表示とaria-valuetextのフォーマットパターン(例:"{value}%"、"{value} of {max}") |
onValueChange | (value: number) => void | - | 値が変更されたときのコールバック |
アクセシビリティのため、label、aria-label、またはaria-labelledbyのいずれかが必要です。
テスト
テストは、ARIA属性、キーボード操作、ポインター操作、アクセシビリティ要件に関するAPG準拠を検証します。
テストカテゴリ
高優先度: ARIA属性
| テスト | 説明 |
|---|---|
role="slider" | 要素がsliderロールを持つ |
aria-valuenow | 現在の値が正しく設定され更新される |
aria-valuemin | 最小値が設定されている(デフォルト: 0) |
aria-valuemax | 最大値が設定されている(デフォルト: 100) |
aria-valuetext | 提供された場合に人間が読めるテキストが設定されている |
aria-disabled | 設定された場合に無効化状態が反映されている |
高優先度: アクセシブルな名前
| テスト | 説明 |
|---|---|
aria-label | aria-label属性によるアクセシブルな名前 |
aria-labelledby | 外部要素参照によるアクセシブルな名前 |
visible label | 可視ラベルがアクセシブルな名前を提供する |
高優先度: キーボード操作
| テスト | 説明 |
|---|---|
Arrow Right/Up | 値を1ステップ増加させる |
Arrow Left/Down | 値を1ステップ減少させる |
Home | 値を最小値に設定する |
End | 値を最大値に設定する |
Page Up/Down | 値を大きなステップで増加/減少させる |
Boundary clamping | 値が最小/最大の制限を超えない |
Disabled state | 無効化時にキーボードが効果を持たない |
高優先度: フォーカス管理
| テスト | 説明 |
|---|---|
tabindex="0" | つまみがフォーカス可能である |
tabindex="-1" | 無効化時につまみがフォーカス可能でない |
高優先度: 向き
| テスト | 説明 |
|---|---|
horizontal | 水平スライダーにはaria-orientationがない |
vertical | aria-orientation="vertical"が設定されている |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe violations | axe-coreによってアクセシビリティ違反が検出されない |
中優先度: エッジケース
| テスト | 説明 |
|---|---|
decimal values | 小数のステップ値を正しく処理する |
negative range | 負の最小/最大範囲を処理する |
clamp to min | 最小値未満のdefaultValueが最小値にクランプされる |
clamp to max | 最大値超過のdefaultValueが最大値にクランプされる |
中優先度: コールバック
| テスト | 説明 |
|---|---|
onValueChange | 変更時に新しい値とともにコールバックが呼ばれる |
低優先度: HTML属性の継承
| テスト | 説明 |
|---|---|
className | カスタムクラスがコンテナに適用される |
id | ID属性が正しく設定される |
data-* | データ属性が渡される |
テストツール
- React: React Testing Library (opens in new tab)
- Vue: Vue Testing Library (opens in new tab)
- Svelte: Svelte Testing Library (opens in new tab)
- Astro: Web Componentユニットテスト用のVitest with JSDOM
- Accessibility: axe-core (opens in new tab)
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 { Slider } from './Slider';
describe('Slider', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="slider"', () => {
render(<Slider aria-label="Volume" />);
expect(screen.getByRole('slider')).toBeInTheDocument();
});
it('has aria-valuenow set to current value', () => {
render(<Slider defaultValue={50} aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuenow', '50');
});
it('has aria-valuenow set to min when no defaultValue', () => {
render(<Slider min={10} aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuenow', '10');
});
it('has aria-valuemin set (default: 0)', () => {
render(<Slider aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuemin', '0');
});
it('has aria-valuemax set (default: 100)', () => {
render(<Slider aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuemax', '100');
});
it('has custom aria-valuemin when provided', () => {
render(<Slider defaultValue={50} min={10} aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuemin', '10');
});
it('has custom aria-valuemax when provided', () => {
render(<Slider defaultValue={50} max={200} aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuemax', '200');
});
it('has aria-valuetext when valueText provided', () => {
render(<Slider defaultValue={75} valueText="75 percent" aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuetext', '75 percent');
});
it('does not have aria-valuetext when not provided', () => {
render(<Slider defaultValue={75} aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).not.toHaveAttribute('aria-valuetext');
});
it('uses format for aria-valuetext', () => {
render(<Slider defaultValue={75} format="{value}%" aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuetext', '75%');
});
it('updates aria-valuetext on value change', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} format="{value}%" aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowRight}');
expect(slider).toHaveAttribute('aria-valuetext', '51%');
});
it('has aria-disabled="true" when disabled', () => {
render(<Slider disabled aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-disabled', 'true');
});
it('does not have aria-disabled when not disabled', () => {
render(<Slider aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).not.toHaveAttribute('aria-disabled');
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has accessible name via aria-label', () => {
render(<Slider aria-label="Volume" />);
expect(screen.getByRole('slider', { name: 'Volume' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(
<>
<span id="slider-label">Brightness</span>
<Slider aria-labelledby="slider-label" />
</>
);
expect(screen.getByRole('slider', { name: 'Brightness' })).toBeInTheDocument();
});
it('has accessible name via visible label', () => {
render(<Slider label="Zoom Level" />);
expect(screen.getByRole('slider', { name: 'Zoom Level' })).toBeInTheDocument();
});
});
// 🔴 High Priority: Keyboard Interaction
describe('Keyboard Interaction', () => {
it('increases value by step on ArrowRight', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowRight}');
expect(slider).toHaveAttribute('aria-valuenow', '51');
});
it('decreases value by step on ArrowLeft', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowLeft}');
expect(slider).toHaveAttribute('aria-valuenow', '49');
});
it('increases value by step on ArrowUp', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowUp}');
expect(slider).toHaveAttribute('aria-valuenow', '51');
});
it('decreases value by step on ArrowDown', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowDown}');
expect(slider).toHaveAttribute('aria-valuenow', '49');
});
it('sets min value on Home', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} min={0} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{Home}');
expect(slider).toHaveAttribute('aria-valuenow', '0');
});
it('sets max value on End', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} max={100} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{End}');
expect(slider).toHaveAttribute('aria-valuenow', '100');
});
it('increases value by large step on PageUp', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{PageUp}');
expect(slider).toHaveAttribute('aria-valuenow', '60'); // default largeStep = step * 10
});
it('decreases value by large step on PageDown', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{PageDown}');
expect(slider).toHaveAttribute('aria-valuenow', '40');
});
it('uses custom largeStep when provided', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} step={1} largeStep={20} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{PageUp}');
expect(slider).toHaveAttribute('aria-valuenow', '70');
});
it('respects custom step value', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} step={5} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowRight}');
expect(slider).toHaveAttribute('aria-valuenow', '55');
});
it('does not exceed max on ArrowRight', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={100} max={100} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowRight}');
expect(slider).toHaveAttribute('aria-valuenow', '100');
});
it('does not go below min on ArrowLeft', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={0} min={0} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowLeft}');
expect(slider).toHaveAttribute('aria-valuenow', '0');
});
it('does not change value when disabled', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} disabled aria-label="Volume" />);
const slider = screen.getByRole('slider');
slider.focus();
await user.keyboard('{ArrowRight}');
expect(slider).toHaveAttribute('aria-valuenow', '50');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" on thumb', () => {
render(<Slider aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('tabindex', '0');
});
it('has tabindex="-1" when disabled', () => {
render(<Slider disabled aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('tabindex', '-1');
});
it('is focusable via Tab', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<Slider aria-label="Volume" />
<button>After</button>
</>
);
await user.tab(); // Focus "Before" button
await user.tab(); // Focus slider
expect(screen.getByRole('slider')).toHaveFocus();
});
it('is not focusable via Tab when disabled', async () => {
const user = userEvent.setup();
render(
<>
<button>Before</button>
<Slider disabled aria-label="Volume" />
<button>After</button>
</>
);
await user.tab(); // Focus "Before" button
await user.tab(); // Focus "After" button (skip slider)
expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
});
});
// 🔴 High Priority: Orientation
describe('Orientation', () => {
it('does not have aria-orientation for horizontal slider', () => {
render(<Slider orientation="horizontal" aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).not.toHaveAttribute('aria-orientation');
});
it('has aria-orientation="vertical" for vertical slider', () => {
render(<Slider orientation="vertical" aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-orientation', 'vertical');
});
it('keyboard works correctly for vertical slider', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={50} orientation="vertical" aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowUp}');
expect(slider).toHaveAttribute('aria-valuenow', '51');
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(<Slider aria-label="Volume" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with visible label', async () => {
const { container } = render(<Slider label="Volume" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with aria-labelledby', async () => {
const { container } = render(
<>
<span id="label">Volume</span>
<Slider aria-labelledby="label" />
</>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = render(<Slider disabled aria-label="Volume" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with valueText', async () => {
const { container } = render(
<Slider defaultValue={50} valueText="50%" aria-label="Volume" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations at boundary values', async () => {
const { container } = render(<Slider defaultValue={0} aria-label="Volume" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations for vertical slider', async () => {
const { container } = render(<Slider orientation="vertical" aria-label="Volume" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Callbacks
describe('Callbacks', () => {
it('calls onValueChange on keyboard interaction', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(<Slider defaultValue={50} onValueChange={handleChange} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowRight}');
expect(handleChange).toHaveBeenCalledWith(51);
});
it('calls onValueChange with correct value on Home', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(<Slider defaultValue={50} min={0} onValueChange={handleChange} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{Home}');
expect(handleChange).toHaveBeenCalledWith(0);
});
it('does not call onValueChange when disabled', async () => {
const handleChange = vi.fn();
const user = userEvent.setup();
render(
<Slider defaultValue={50} disabled onValueChange={handleChange} aria-label="Volume" />
);
const slider = screen.getByRole('slider');
slider.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(
<Slider defaultValue={100} max={100} onValueChange={handleChange} aria-label="Volume" />
);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowRight}');
expect(handleChange).not.toHaveBeenCalled();
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('handles decimal step values correctly', async () => {
const user = userEvent.setup();
render(<Slider defaultValue={0.5} min={0} max={1} step={0.1} aria-label="Volume" />);
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowRight}');
expect(slider).toHaveAttribute('aria-valuenow', '0.6');
});
it('handles negative min/max range', () => {
render(<Slider defaultValue={0} min={-50} max={50} aria-label="Temperature" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuenow', '0');
expect(slider).toHaveAttribute('aria-valuemin', '-50');
expect(slider).toHaveAttribute('aria-valuemax', '50');
});
it('clamps defaultValue to min', () => {
render(<Slider defaultValue={-10} min={0} max={100} aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuenow', '0');
});
it('clamps defaultValue to max', () => {
render(<Slider defaultValue={150} min={0} max={100} aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuenow', '100');
});
it('rounds value to step', () => {
render(<Slider defaultValue={53} min={0} max={100} step={5} aria-label="Volume" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuenow', '55');
});
});
// 🟡 Medium Priority: Visual Display
describe('Visual Display', () => {
it('shows value when showValue is true (default)', () => {
render(<Slider defaultValue={75} aria-label="Volume" />);
expect(screen.getByText('75')).toBeInTheDocument();
});
it('hides value when showValue is false', () => {
render(<Slider defaultValue={75} aria-label="Volume" showValue={false} />);
expect(screen.queryByText('75')).not.toBeInTheDocument();
});
it('displays formatted value when format provided', () => {
render(<Slider defaultValue={75} format="{value}%" aria-label="Volume" />);
expect(screen.getByText('75%')).toBeInTheDocument();
});
it('displays visible label when label provided', () => {
render(<Slider label="Volume" />);
expect(screen.getByText('Volume')).toBeInTheDocument();
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('applies className to container', () => {
render(<Slider aria-label="Volume" className="custom-slider" />);
const container = screen.getByRole('slider').closest('.apg-slider');
expect(container).toHaveClass('custom-slider');
});
it('sets id attribute on slider element', () => {
render(<Slider aria-label="Volume" id="my-slider" />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('id', 'my-slider');
});
it('passes through data-* attributes', () => {
render(<Slider aria-label="Volume" data-testid="custom-slider" />);
expect(screen.getByTestId('custom-slider')).toBeInTheDocument();
});
it('supports aria-describedby', () => {
render(
<>
<Slider aria-label="Volume" aria-describedby="desc" />
<p id="desc">Adjust the volume level</p>
</>
);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-describedby', 'desc');
});
});
});