Slider
ユーザーが範囲内から値を選択できるインタラクティブなコントロール。
デモ
Native HTML
ネイティブ HTML を優先
このカスタムコンポーネントを使用する前に、ネイティブの<input type="range">要素の使用を検討してください。組み込みのキーボードサポート、JavaScriptなしでの動作、ネイティブのアクセシビリティサポートを提供します。
<label for="volume">Volume</label>
<input type="range" id="volume" min="0" max="100" value="50">
ネイティブ要素では提供できないカスタムスタイリングが必要な場合、またはインタラクション中に特定の視覚的フィードバックが必要な場合にのみ、カスタム実装を使用してください。
| ユースケース | ネイティブ HTML | カスタム実装 |
|---|---|---|
| 基本的な値の選択 | 推奨 | 不要 |
| キーボードサポート | 組み込み | 手動実装 |
| JavaScript無効時のサポート | ネイティブで動作 | フォールバック必要 |
| フォーム統合 | 組み込み | 手動実装 |
| カスタムスタイリング | 限定的(疑似要素) | 完全な制御 |
| ブラウザ間で一貫した外観 | 大きく異なる | 一貫性あり |
| 垂直方向 | 限定的なブラウザサポート | 完全な制御 |
ネイティブの<input type="range">のスタイリングは、ブラウザ間で一貫性がないことで知られています。スタイリングにはベンダー固有の疑似要素(::-webkit-slider-thumb、::-moz-range-thumbなど)が必要で、メンテナンスが複雑になる可能性があります。
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
slider | つまみ要素 | ユーザーが範囲内から値を選択できるスライダーとして要素を識別します |
slider
ロールは、ユーザーがトラックに沿ってつまみを動かして値を選択するインタラクティブなコントロールに使用されます。
meter
ロールとは異なり、スライダーはインタラクティブでキーボードフォーカスを受け取ります。
WAI-ARIA プロパティ
aria-valuenow (必須)
スライダーの現在の数値を示します。ユーザーが値を変更すると動的に更新されます。
| 型 | Number |
| 必須 | はい |
| 範囲 | aria-valuemin と aria-valuemax
の間である必要があります
|
aria-valuemin (必須)
スライダーの許容最小値を指定します。
| 型 | Number |
| 必須 | はい |
| デフォルト | 0 |
aria-valuemax (必須)
スライダーの許容最大値を指定します。
| 型 | Number |
| 必須 | はい |
| デフォルト | 100 |
aria-valuetext
現在の値の人間が読めるテキスト代替を提供します。数値だけでは十分な意味を伝えられない場合に使用します。
| 型 | String |
| 必須 | いいえ(値にコンテキストが必要な場合は推奨) |
| 例 | "50%", "Medium", "3 of 5 stars" |
aria-orientation
スライダーの向きを指定します。垂直スライダーの場合のみ "vertical" に設定します。水平の場合は省略します(デフォルト)。
| 型 | "horizontal" | "vertical" |
| 必須 | いいえ |
| デフォルト | horizontal(暗黙的) |
aria-disabled
スライダーが無効でインタラクティブではないことを示します。
| 型 | true | undefined |
| 必須 | いいえ |
キーボードサポート
| キー | アクション |
|---|---|
| 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ハイコントラストモードでのアクセシビリティのためにシステムカラーを使用
参考資料
ソースコード
<script lang="ts">
interface SliderProps {
defaultValue?: number;
min?: number;
max?: number;
step?: number;
largeStep?: number;
orientation?: 'horizontal' | 'vertical';
disabled?: boolean;
showValue?: boolean;
label?: string;
valueText?: string;
/** Format pattern for dynamic value display (e.g., "{value}%", "{value} of {max}") */
format?: string;
onvaluechange?: (value: number) => void;
[key: string]: unknown;
}
let {
defaultValue,
min = 0,
max = 100,
step = 1,
largeStep,
orientation = 'horizontal',
disabled = false,
showValue = true,
label,
valueText,
format,
onvaluechange,
...restProps
}: SliderProps = $props();
// Utility functions
function clamp(val: number, minVal: number, maxVal: number): number {
return Math.min(maxVal, Math.max(minVal, val));
}
function roundToStep(val: number, stepVal: number, minVal: number): number {
const steps = Math.round((val - minVal) / stepVal);
const result = minVal + steps * stepVal;
const decimalPlaces = (stepVal.toString().split('.')[1] || '').length;
return Number(result.toFixed(decimalPlaces));
}
function getPercent(val: number, minVal: number, maxVal: number): number {
if (maxVal === minVal) return 0;
return ((val - minVal) / (maxVal - minVal)) * 100;
}
// Format value helper
function formatValueText(
val: number,
formatStr: string | undefined,
minVal: number,
maxVal: number
): string {
if (!formatStr) return String(val);
return formatStr
.replace('{value}', String(val))
.replace('{min}', String(minVal))
.replace('{max}', String(maxVal));
}
// Generate unique ID for label
const labelId = `slider-label-${Math.random().toString(36).slice(2, 9)}`;
// Refs
let thumbEl: HTMLDivElement | null = null;
let trackEl: HTMLDivElement | null = null;
let isDragging = $state(false);
// State - calculate initial value inline (warnings are acceptable for uncontrolled component)
let value = $state(clamp(roundToStep(defaultValue ?? min, step, min), min, max));
// Computed
const isVertical = $derived(orientation === 'vertical');
const effectiveLargeStep = $derived(largeStep ?? step * 10);
const percent = $derived(getPercent(value, min, max));
const ariaValueText = $derived(
valueText ?? (format ? formatValueText(value, format, min, max) : undefined)
);
const displayText = $derived(valueText ? valueText : formatValueText(value, format, min, max));
const ariaLabelledby = $derived(restProps['aria-labelledby'] ?? (label ? labelId : undefined));
// Update value and dispatch event
function updateValue(newValue: number) {
const clampedValue = clamp(roundToStep(newValue, step, min), min, max);
if (clampedValue !== value) {
value = clampedValue;
onvaluechange?.(clampedValue);
}
}
// Calculate value from pointer position
function getValueFromPointer(clientX: number, clientY: number): number {
if (!trackEl) return value;
const rect = trackEl.getBoundingClientRect();
// Guard against zero-size track
if (rect.width === 0 && rect.height === 0) {
return value;
}
let pct: number;
if (isVertical) {
if (rect.height === 0) return value;
pct = 1 - (clientY - rect.top) / rect.height;
} else {
if (rect.width === 0) return value;
pct = (clientX - rect.left) / rect.width;
}
const rawValue = min + pct * (max - min);
return clamp(roundToStep(rawValue, step, min), min, max);
}
// Keyboard handler
function handleKeyDown(event: 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;
}
event.preventDefault();
updateValue(newValue);
}
// Pointer handlers
function handlePointerDown(event: PointerEvent) {
if (disabled) return;
event.preventDefault();
if (!thumbEl) return;
if (typeof thumbEl.setPointerCapture === 'function') {
thumbEl.setPointerCapture(event.pointerId);
}
isDragging = true;
thumbEl.focus();
const newValue = getValueFromPointer(event.clientX, event.clientY);
updateValue(newValue);
}
function handlePointerMove(event: PointerEvent) {
if (!thumbEl) return;
const hasCapture =
typeof thumbEl.hasPointerCapture === 'function'
? thumbEl.hasPointerCapture(event.pointerId)
: isDragging;
if (!hasCapture) return;
const newValue = getValueFromPointer(event.clientX, event.clientY);
updateValue(newValue);
}
function handlePointerUp(event: PointerEvent) {
if (thumbEl && typeof thumbEl.releasePointerCapture === 'function') {
try {
thumbEl.releasePointerCapture(event.pointerId);
} catch {
// Ignore
}
}
isDragging = false;
}
// Track click handler
function handleTrackClick(event: MouseEvent) {
if (disabled) return;
if (event.target === thumbEl) return;
const newValue = getValueFromPointer(event.clientX, event.clientY);
updateValue(newValue);
thumbEl?.focus();
}
</script>
<div
class="apg-slider {isVertical ? 'apg-slider--vertical' : ''} {disabled
? 'apg-slider--disabled'
: ''} {restProps.class || ''}"
>
{#if label}
<span id={labelId} class="apg-slider-label">
{label}
</span>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
bind:this={trackEl}
class="apg-slider-track"
style="--slider-position: {percent}%"
onclick={handleTrackClick}
>
<div class="apg-slider-fill" aria-hidden="true"></div>
<div
bind:this={thumbEl}
role="slider"
id={restProps.id}
tabindex={disabled ? -1 : 0}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={ariaValueText}
aria-label={restProps['aria-label']}
aria-labelledby={ariaLabelledby}
aria-orientation={isVertical ? 'vertical' : undefined}
aria-disabled={disabled ? true : undefined}
aria-describedby={restProps['aria-describedby']}
data-testid={restProps['data-testid']}
class="apg-slider-thumb"
onkeydown={handleKeyDown}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
></div>
</div>
{#if showValue}
<span class="apg-slider-value" aria-hidden="true">
{displayText}
</span>
{/if}
</div> 使い方
<script>
import Slider from './Slider.svelte';
function handleChange(value) {
console.log('Value changed:', value);
}
</script>
<!-- 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={handleChange}
/> 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 | 最小値が設定されている |
aria-valuemax | 最大値が設定されている |
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 | 値がmin/max制限を超えない |
Disabled state | 無効時はキーボード操作が無効 |
高優先度: フォーカス管理
| テスト | 説明 |
|---|---|
tabindex="0" | つまみがフォーカス可能 |
tabindex="-1" | 無効時はつまみがフォーカス不可 |
高優先度: 向き
| テスト | 説明 |
|---|---|
horizontal | 水平スライダーにはaria-orientationなし |
vertical | aria-orientation="vertical"が設定される |
中優先度: アクセシビリティ
| テスト | 説明 |
|---|---|
axe violations | axe-coreによるアクセシビリティ違反なし |
中優先度: エッジケース
| テスト | 説明 |
|---|---|
decimal values | 小数ステップ値を正しく処理 |
negative range | 負のmin/max範囲を処理 |
clamp to min | min未満のdefaultValueはminにクランプ |
clamp to max | max超過のdefaultValueはmaxにクランプ |
中優先度: コールバック
| テスト | 説明 |
|---|---|
onValueChange | 変更時に新しい値でコールバックが呼ばれる |
低優先度: HTML属性継承
| テスト | 説明 |
|---|---|
className | カスタムクラスがコンテナに適用される |
id | ID属性が正しく設定される |
data-* | データ属性が引き継がれる |
低優先度: フレームワーク間の一貫性
| テスト | 説明 |
|---|---|
All frameworks render sliders | React、Vue、Svelte、Astroすべてがスライダー要素をレンダリング |
Consistent ARIA attributes | すべてのフレームワークで一貫したaria-valuenow、aria-valuemin、aria-valuemax |
Keyboard navigation | すべてのフレームワークが矢印キー、Home、Endキーボードナビゲーションをサポート |
テストコード例
以下は実際のE2Eテストファイル(e2e/slider.spec.ts)です。
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Slider Pattern
*
* An interactive control that allows users to select a value from within a range.
* Uses role="slider" with aria-valuenow, aria-valuemin, and aria-valuemax.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/slider/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// ============================================
// Helper Functions
// ============================================
const getSlider = (page: import('@playwright/test').Page) => {
return page.getByRole('slider');
};
const getSliderByLabel = (page: import('@playwright/test').Page, label: string) => {
return page.getByRole('slider', { name: label });
};
// ============================================
// Framework-specific Tests
// ============================================
for (const framework of frameworks) {
test.describe(`Slider (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/slider/${framework}/demo/`);
await getSlider(page).first().waitFor();
// Wait for hydration - slider should have aria-valuenow
const firstSlider = getSlider(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('slider has role="slider"', async ({ page }) => {
const slider = getSlider(page).first();
await expect(slider).toHaveRole('slider');
});
test('slider has aria-valuenow', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
const valuenow = await slider.getAttribute('aria-valuenow');
expect(valuenow).toBe('50');
});
test('slider has aria-valuemin', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
const valuemin = await slider.getAttribute('aria-valuemin');
expect(valuemin).toBe('0');
});
test('slider has aria-valuemax', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
const valuemax = await slider.getAttribute('aria-valuemax');
expect(valuemax).toBe('100');
});
test('slider with custom range has correct min/max', async ({ page }) => {
const slider = getSliderByLabel(page, 'Rating');
await expect(slider).toHaveAttribute('aria-valuemin', '1');
await expect(slider).toHaveAttribute('aria-valuemax', '5');
await expect(slider).toHaveAttribute('aria-valuenow', '3');
});
test('slider has accessible name via label', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await expect(slider).toBeVisible();
});
test('slider has aria-valuetext when format is provided', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await expect(slider).toHaveAttribute('aria-valuetext', '50%');
});
test('rating slider has descriptive aria-valuetext', async ({ page }) => {
const slider = getSliderByLabel(page, 'Rating');
await expect(slider).toHaveAttribute('aria-valuetext', '3 of 5');
});
test('vertical slider has aria-orientation="vertical"', async ({ page }) => {
const slider = getSliderByLabel(page, 'Vertical');
await expect(slider).toHaveAttribute('aria-orientation', 'vertical');
});
test('disabled slider has aria-disabled="true"', async ({ page }) => {
const slider = getSliderByLabel(page, 'Disabled');
await expect(slider).toHaveAttribute('aria-disabled', 'true');
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Interaction
// ------------------------------------------
test.describe('APG: Keyboard Interaction', () => {
test('ArrowRight increases value by step', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
const initialValue = await slider.getAttribute('aria-valuenow');
await slider.press('ArrowRight');
const newValue = await slider.getAttribute('aria-valuenow');
expect(Number(newValue)).toBe(Number(initialValue) + 1);
});
test('ArrowLeft decreases value by step', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
const initialValue = await slider.getAttribute('aria-valuenow');
await slider.press('ArrowLeft');
const newValue = await slider.getAttribute('aria-valuenow');
expect(Number(newValue)).toBe(Number(initialValue) - 1);
});
test('ArrowUp increases value by step', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
const initialValue = await slider.getAttribute('aria-valuenow');
await slider.press('ArrowUp');
const newValue = await slider.getAttribute('aria-valuenow');
expect(Number(newValue)).toBe(Number(initialValue) + 1);
});
test('ArrowDown decreases value by step', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
const initialValue = await slider.getAttribute('aria-valuenow');
await slider.press('ArrowDown');
const newValue = await slider.getAttribute('aria-valuenow');
expect(Number(newValue)).toBe(Number(initialValue) - 1);
});
test('Home sets value to minimum', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
await slider.press('Home');
await expect(slider).toHaveAttribute('aria-valuenow', '0');
});
test('End sets value to maximum', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
await slider.press('End');
await expect(slider).toHaveAttribute('aria-valuenow', '100');
});
test('PageUp increases value by large step', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
const initialValue = await slider.getAttribute('aria-valuenow');
await slider.press('PageUp');
const newValue = await slider.getAttribute('aria-valuenow');
// Large step is typically step * 10 = 10
expect(Number(newValue)).toBe(Number(initialValue) + 10);
});
test('PageDown decreases value by large step', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
const initialValue = await slider.getAttribute('aria-valuenow');
await slider.press('PageDown');
const newValue = await slider.getAttribute('aria-valuenow');
// Large step is typically step * 10 = 10
expect(Number(newValue)).toBe(Number(initialValue) - 10);
});
test('value does not exceed maximum', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
// Set to max first
await slider.press('End');
await expect(slider).toHaveAttribute('aria-valuenow', '100');
// Try to go beyond max
await slider.press('ArrowRight');
await expect(slider).toHaveAttribute('aria-valuenow', '100');
});
test('value does not go below minimum', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
// Set to min first
await slider.press('Home');
await expect(slider).toHaveAttribute('aria-valuenow', '0');
// Try to go below min
await slider.press('ArrowLeft');
await expect(slider).toHaveAttribute('aria-valuenow', '0');
});
test('Rating slider respects step of 1', async ({ page }) => {
const slider = getSliderByLabel(page, 'Rating');
await slider.click();
await expect(slider).toBeFocused();
// Initial value might change due to click position, so use Home first
await slider.press('Home');
await expect(slider).toHaveAttribute('aria-valuenow', '1');
await slider.press('ArrowRight');
await expect(slider).toHaveAttribute('aria-valuenow', '2');
await slider.press('End');
await expect(slider).toHaveAttribute('aria-valuenow', '5');
// Should not exceed max
await slider.press('ArrowRight');
await expect(slider).toHaveAttribute('aria-valuenow', '5');
});
});
// ------------------------------------------
// 🔴 High Priority: Vertical Slider Keyboard
// ------------------------------------------
test.describe('APG: Vertical Slider Keyboard', () => {
test('ArrowUp increases value in vertical slider', async ({ page }) => {
const slider = getSliderByLabel(page, 'Vertical');
await slider.click();
await expect(slider).toBeFocused();
const initialValue = await slider.getAttribute('aria-valuenow');
await slider.press('ArrowUp');
const newValue = await slider.getAttribute('aria-valuenow');
expect(Number(newValue)).toBe(Number(initialValue) + 1);
});
test('ArrowDown decreases value in vertical slider', async ({ page }) => {
const slider = getSliderByLabel(page, 'Vertical');
await slider.click();
await expect(slider).toBeFocused();
const initialValue = await slider.getAttribute('aria-valuenow');
await slider.press('ArrowDown');
const newValue = await slider.getAttribute('aria-valuenow');
expect(Number(newValue)).toBe(Number(initialValue) - 1);
});
});
// ------------------------------------------
// 🔴 High Priority: Focus Management
// ------------------------------------------
test.describe('APG: Focus Management', () => {
test('slider is focusable', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
});
test('slider has tabindex="0"', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await expect(slider).toHaveAttribute('tabindex', '0');
});
test('disabled slider has tabindex="-1"', async ({ page }) => {
const slider = getSliderByLabel(page, 'Disabled');
await expect(slider).toHaveAttribute('tabindex', '-1');
});
});
// ------------------------------------------
// 🟡 Medium Priority: Disabled State
// ------------------------------------------
test.describe('Disabled State', () => {
test('disabled slider does not change value on ArrowRight', async ({ page }) => {
const slider = getSliderByLabel(page, 'Disabled');
// Force focus via JavaScript (click won't work on disabled)
await slider.evaluate((el) => (el as HTMLElement).focus());
const initialValue = await slider.getAttribute('aria-valuenow');
await page.keyboard.press('ArrowRight');
const newValue = await slider.getAttribute('aria-valuenow');
expect(newValue).toBe(initialValue);
});
test('disabled slider does not change value on Home', async ({ page }) => {
const slider = getSliderByLabel(page, 'Disabled');
// Force focus via JavaScript
await slider.evaluate((el) => (el as HTMLElement).focus());
await page.keyboard.press('Home');
// Should still be 50 (default value)
await expect(slider).toHaveAttribute('aria-valuenow', '50');
});
});
// ------------------------------------------
// 🟡 Medium Priority: aria-valuetext Updates
// ------------------------------------------
test.describe('aria-valuetext Updates', () => {
test('aria-valuetext updates on value change', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
// Use Home to reset to known value
await slider.press('Home');
await expect(slider).toHaveAttribute('aria-valuetext', '0%');
await slider.press('ArrowRight');
await expect(slider).toHaveAttribute('aria-valuetext', '1%');
});
test('Rating slider aria-valuetext updates correctly', async ({ page }) => {
const slider = getSliderByLabel(page, 'Rating');
await slider.click();
await expect(slider).toBeFocused();
// Use Home to reset to known value (min=1)
await slider.press('Home');
await expect(slider).toHaveAttribute('aria-valuetext', '1 of 5');
await slider.press('ArrowRight');
await expect(slider).toHaveAttribute('aria-valuetext', '2 of 5');
});
});
// ------------------------------------------
// 🟢 Low Priority: Accessibility
// ------------------------------------------
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
const results = await new AxeBuilder({ page }).include('[role="slider"]').analyze();
expect(results.violations).toEqual([]);
});
test('vertical slider has no axe-core violations', async ({ page }) => {
const slider = getSliderByLabel(page, 'Vertical');
await slider.scrollIntoViewIfNeeded();
const results = await new AxeBuilder({ page })
.include('[aria-orientation="vertical"]')
.analyze();
expect(results.violations).toEqual([]);
});
test('disabled slider has no axe-core violations', async ({ page }) => {
const slider = getSliderByLabel(page, 'Disabled');
await slider.scrollIntoViewIfNeeded();
const results = await new AxeBuilder({ page }).include('[aria-disabled="true"]').analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// ============================================
// Cross-framework Consistency Tests
// ============================================
test.describe('Slider - Cross-framework Consistency', () => {
test('all frameworks render sliders', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/slider/${framework}/demo/`);
await getSlider(page).first().waitFor();
const sliders = getSlider(page);
const count = await sliders.count();
expect(count).toBeGreaterThanOrEqual(4); // Volume, Rating, Vertical, Disabled
}
});
test('all frameworks have consistent ARIA attributes', async ({ page }) => {
test.setTimeout(60000);
for (const framework of frameworks) {
await page.goto(`patterns/slider/${framework}/demo/`);
await getSlider(page).first().waitFor();
// Wait for hydration
await expect
.poll(async () => {
const valuenow = await getSlider(page).first().getAttribute('aria-valuenow');
return valuenow !== null;
})
.toBe(true);
// Check Volume slider
const volumeSlider = getSliderByLabel(page, 'Volume');
await expect(volumeSlider).toHaveAttribute('aria-valuenow', '50');
await expect(volumeSlider).toHaveAttribute('aria-valuemin', '0');
await expect(volumeSlider).toHaveAttribute('aria-valuemax', '100');
await expect(volumeSlider).toHaveAttribute('aria-valuetext', '50%');
// Check Rating slider
const ratingSlider = getSliderByLabel(page, 'Rating');
await expect(ratingSlider).toHaveAttribute('aria-valuenow', '3');
await expect(ratingSlider).toHaveAttribute('aria-valuemin', '1');
await expect(ratingSlider).toHaveAttribute('aria-valuemax', '5');
// Check Vertical slider
const verticalSlider = getSliderByLabel(page, 'Vertical');
await expect(verticalSlider).toHaveAttribute('aria-orientation', 'vertical');
// Check Disabled slider
const disabledSlider = getSliderByLabel(page, 'Disabled');
await expect(disabledSlider).toHaveAttribute('aria-disabled', 'true');
}
});
test('all frameworks support keyboard navigation', async ({ page }) => {
test.setTimeout(60000);
for (const framework of frameworks) {
await page.goto(`patterns/slider/${framework}/demo/`);
await getSlider(page).first().waitFor();
// Wait for hydration
await expect
.poll(async () => {
const valuenow = await getSlider(page).first().getAttribute('aria-valuenow');
return valuenow !== null;
})
.toBe(true);
const slider = getSliderByLabel(page, 'Volume');
await slider.click();
await expect(slider).toBeFocused();
// Test Home (to reset to known value after click)
await slider.press('Home');
await expect(slider).toHaveAttribute('aria-valuenow', '0');
// Test ArrowRight
await slider.press('ArrowRight');
await expect(slider).toHaveAttribute('aria-valuenow', '1');
// Test End
await slider.press('End');
await expect(slider).toHaveAttribute('aria-valuenow', '100');
}
});
}); テストの実行
# Run unit tests for Slider
npm run test -- slider
# Run E2E tests for Slider (all frameworks)
npm run test:e2e:pattern --pattern=slider
# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=slider
npm run test:e2e:vue:pattern --pattern=slider
npm run test:e2e:svelte:pattern --pattern=slider
npm run test:e2e:astro:pattern --pattern=slider テストツール
- Vitest (opens in new tab) - ユニットテストランナー
- Testing Library (opens in new tab) - フレームワーク別テストユーティリティ
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core (opens in new tab) - アクセシビリティテストエンジン
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Slider from './Slider.svelte';
import SliderWithLabel from './SliderWithLabel.test.svelte';
describe('Slider (Svelte)', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="slider"', () => {
render(Slider, {
props: { 'aria-label': 'Volume' },
});
expect(screen.getByRole('slider')).toBeInTheDocument();
});
it('has aria-valuenow set to current value', () => {
render(Slider, {
props: { 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, {
props: { 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, {
props: { 'aria-label': 'Volume' },
});
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuemin', '0');
});
it('has aria-valuemax set (default: 100)', () => {
render(Slider, {
props: { 'aria-label': 'Volume' },
});
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuemax', '100');
});
it('has custom aria-valuemin when provided', () => {
render(Slider, {
props: { 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, {
props: { 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, {
props: { 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, {
props: { defaultValue: 75, 'aria-label': 'Volume' },
});
const slider = screen.getByRole('slider');
expect(slider).not.toHaveAttribute('aria-valuetext');
});
it('uses format for aria-valuetext', () => {
render(Slider, {
props: {
defaultValue: 75,
format: '{value}%',
'aria-label': 'Volume',
},
});
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuetext', '75%');
});
it('has aria-disabled="true" when disabled', () => {
render(Slider, {
props: { disabled: true, '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, {
props: { '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, {
props: { 'aria-label': 'Volume' },
});
expect(screen.getByRole('slider', { name: 'Volume' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(SliderWithLabel);
expect(screen.getByRole('slider', { name: 'Brightness' })).toBeInTheDocument();
});
it('has accessible name via visible label', () => {
render(Slider, {
props: { 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, {
props: { 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, {
props: { 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('sets min value on Home', async () => {
const user = userEvent.setup();
render(Slider, {
props: { 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, {
props: { 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, {
props: { 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');
});
it('does not exceed max on ArrowRight', async () => {
const user = userEvent.setup();
render(Slider, {
props: { 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 change value when disabled', async () => {
const user = userEvent.setup();
render(Slider, {
props: { defaultValue: 50, disabled: true, '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, {
props: { 'aria-label': 'Volume' },
});
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('tabindex', '0');
});
it('has tabindex="-1" when disabled', () => {
render(Slider, {
props: { disabled: true, 'aria-label': 'Volume' },
});
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('tabindex', '-1');
});
});
// 🔴 High Priority: Orientation
describe('Orientation', () => {
it('does not have aria-orientation for horizontal slider', () => {
render(Slider, {
props: { 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, {
props: { orientation: 'vertical', 'aria-label': 'Volume' },
});
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-orientation', 'vertical');
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(Slider, {
props: { 'aria-label': 'Volume' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with visible label', async () => {
const { container } = render(Slider, {
props: { label: 'Volume' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when disabled', async () => {
const { container } = render(Slider, {
props: { disabled: true, 'aria-label': 'Volume' },
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations for vertical slider', async () => {
const { container } = render(Slider, {
props: { 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, {
props: { defaultValue: 50, onvaluechange: handleChange, 'aria-label': 'Volume' },
});
const slider = screen.getByRole('slider');
await user.click(slider);
await user.keyboard('{ArrowRight}');
expect(handleChange).toHaveBeenCalledWith(51);
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('handles decimal step values correctly', async () => {
const user = userEvent.setup();
render(Slider, {
props: { 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, {
props: { 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, {
props: { 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, {
props: { defaultValue: 150, min: 0, max: 100, 'aria-label': 'Volume' },
});
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuenow', '100');
});
});
// 🟡 Medium Priority: Visual Display
describe('Visual Display', () => {
it('shows value when showValue is true (default)', () => {
render(Slider, {
props: { defaultValue: 75, 'aria-label': 'Volume' },
});
expect(screen.getByText('75')).toBeInTheDocument();
});
it('hides value when showValue is false', () => {
render(Slider, {
props: { defaultValue: 75, showValue: false, 'aria-label': 'Volume' },
});
expect(screen.queryByText('75')).not.toBeInTheDocument();
});
it('displays formatted value when format provided', () => {
render(Slider, {
props: {
defaultValue: 75,
format: '{value}%',
'aria-label': 'Volume',
},
});
expect(screen.getByText('75%')).toBeInTheDocument();
});
it('displays visible label when label provided', () => {
render(Slider, {
props: { label: 'Volume' },
});
expect(screen.getByText('Volume')).toBeInTheDocument();
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('applies class to container', () => {
render(Slider, {
props: { 'aria-label': 'Volume', class: 'custom-slider' },
});
const container = screen.getByRole('slider').closest('.apg-slider');
expect(container).toHaveClass('custom-slider');
});
it('sets id attribute on slider element', () => {
render(Slider, {
props: { 'aria-label': 'Volume', id: 'my-slider' },
});
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('id', 'my-slider');
});
it('passes through data-* attributes', () => {
render(Slider, {
props: { 'aria-label': 'Volume', 'data-testid': 'custom-slider' },
});
expect(screen.getByTestId('custom-slider')).toBeInTheDocument();
});
});
}); リソース
- WAI-ARIA APG: Slider パターン (opens in new tab)
- MDN: <input type="range"> 要素 (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist