Slider
ユーザーが範囲内から値を選択できるインタラクティブなコントロール。
デモ
ネイティブ 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 |
つまみ要素 | ユーザーが範囲内から値を選択できるスライダーとして要素を識別します |
WAI-ARIA プロパティ
aria-valuenow
キーボードまたはポインターによる値変更時に動的に更新
- 値
- Number
- 必須
- はい
aria-valuemin
デフォルト: 0
- 値
- Number
- 必須
- はい
aria-valuemax
デフォルト: 100
- 値
- Number
- 必須
- はい
aria-valuetext
例: “50%”, “Medium”, “3 of 5 stars”
- 値
- String
- 必須
- いいえ(値にコンテキストが必要な場合は推奨)
aria-orientation
デフォルト: horizontal(暗黙的)。垂直スライダーの場合のみ設定。
- 値
horizontal|vertical- 必須
- いいえ
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) |
- スライダーには表示されるラベル(推奨)、aria-label、またはaria-labelledbyを通じてアクセシブルな名前が必要です。
- この実装は、フォーカスインジケーター、視覚的な塗りつぶし、ホバー状態を含むアクセシブルなビジュアルデザインのWCAGガイドラインに従っています。
- ポインター操作: トラックをクリックするとつまみが即座に移動し、ドラッグで連続的な調整が可能です。
- 強制カラーモードはWindowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用します。
実装ノート
Structure:
<div class="slider-container">
<label id="label">Volume</label>
<div class="slider-track">
<div class="slider-fill" />
<div
role="slider"
tabindex="0"
aria-valuenow="50"
aria-valuemin="0"
aria-valuemax="100"
aria-labelledby="label"
class="slider-thumb"
/>
</div>
</div>
Visual Layout:
┌─────────────────────────────────────┐
│ Volume │
│ ├─────────●─────────────────────────┤
│ 0 50 100 │
└─────────────────────────────────────┘
Keyboard Navigation:
← / ↓ = Decrease by step
→ / ↑ = Increase by step
Home = Set to min
End = Set to max
PgUp = Increase by large step
PgDn = Decrease by large step
Sliderコンポーネントの構造と視覚的なレイアウト
参考資料
ソースコード
Slider.astro
---
/**
* APG Slider Pattern - Astro Implementation
*
* A control that allows users to select a value from within a range.
* Uses Web Components for interactive behavior.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/slider/
*/
export interface Props {
/** Default value */
defaultValue?: number;
/** Minimum value (default: 0) */
min?: number;
/** Maximum value (default: 100) */
max?: number;
/** Step increment (default: 1) */
step?: number;
/** Large step for PageUp/PageDown */
largeStep?: number;
/** Slider orientation */
orientation?: 'horizontal' | 'vertical';
/** Whether slider is disabled */
disabled?: boolean;
/** Show value text (default: true) */
showValue?: boolean;
/** Visible label text */
label?: string;
/** Human-readable value text for aria-valuetext */
valueText?: string;
/** Format pattern for dynamic value display (e.g., "{value}%", "{value} of {max}") */
format?: string;
/** Slider id */
id?: string;
/** Additional CSS class */
class?: string;
/** Accessible label when no visible label */
'aria-label'?: string;
/** Reference to external label element */
'aria-labelledby'?: string;
/** Reference to description element */
'aria-describedby'?: string;
}
const {
defaultValue,
min = 0,
max = 100,
step = 1,
largeStep,
orientation = 'horizontal',
disabled = false,
showValue = true,
label,
valueText,
format,
id,
class: className = '',
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
'aria-describedby': ariaDescribedby,
} = Astro.props;
// Utility functions
const clamp = (val: number, minVal: number, maxVal: number): number => {
return Math.min(maxVal, Math.max(minVal, val));
};
const roundToStep = (val: number, stepVal: number, minVal: number): number => {
const steps = Math.round((val - minVal) / stepVal);
const result = minVal + steps * stepVal;
const decimalPlaces = (stepVal.toString().split('.')[1] || '').length;
return Number(result.toFixed(decimalPlaces));
};
// Calculate initial value
const initialValue = clamp(roundToStep(defaultValue ?? min, step, min), min, max);
// Calculate percentage for visual display
const percentage = max === min ? 0 : ((initialValue - min) / (max - min)) * 100;
// Format value helper
const formatValueText = (value: number, formatStr?: string): string => {
if (!formatStr) return String(value);
return formatStr
.replace('{value}', String(value))
.replace('{min}', String(min))
.replace('{max}', String(max));
};
// Display text
const displayText = valueText ?? formatValueText(initialValue, format);
// Initial aria-valuetext
const initialAriaValueText =
valueText ?? (format ? formatValueText(initialValue, format) : undefined);
// Generate unique label ID
const labelId = label ? `slider-label-${Math.random().toString(36).slice(2, 9)}` : undefined;
const isVertical = orientation === 'vertical';
const effectiveLargeStep = largeStep ?? step * 10;
---
<apg-slider
data-min={min}
data-max={max}
data-step={step}
data-large-step={effectiveLargeStep}
data-orientation={orientation}
data-disabled={disabled}
data-format={format}
>
<div
class={`apg-slider ${isVertical ? 'apg-slider--vertical' : ''} ${disabled ? 'apg-slider--disabled' : ''} ${className}`.trim()}
>
{
label && (
<span id={labelId} class="apg-slider-label">
{label}
</span>
)
}
<div class="apg-slider-track" style={`--slider-position: ${percentage}%`}>
<div class="apg-slider-fill" aria-hidden="true"></div>
<div
role="slider"
id={id}
tabindex={disabled ? -1 : 0}
aria-valuenow={initialValue}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={initialAriaValueText}
aria-label={label ? undefined : ariaLabel}
aria-labelledby={ariaLabelledby ?? labelId}
aria-orientation={isVertical ? 'vertical' : undefined}
aria-disabled={disabled ? true : undefined}
aria-describedby={ariaDescribedby}
class="apg-slider-thumb"
>
</div>
</div>
{
showValue && (
<span class="apg-slider-value" aria-hidden="true">
{displayText}
</span>
)
}
</div>
</apg-slider>
<script>
class ApgSlider extends HTMLElement {
private thumb: HTMLElement | null = null;
private track: HTMLElement | null = null;
private valueDisplay: HTMLElement | null = null;
private isDragging = false;
connectedCallback() {
this.thumb = this.querySelector('[role="slider"]');
this.track = this.querySelector('.apg-slider-track');
this.valueDisplay = this.querySelector('.apg-slider-value');
if (this.thumb) {
this.thumb.addEventListener('keydown', this.handleKeyDown.bind(this));
this.thumb.addEventListener('pointerdown', this.handlePointerDown.bind(this));
this.thumb.addEventListener('pointermove', this.handlePointerMove.bind(this));
this.thumb.addEventListener('pointerup', this.handlePointerUp.bind(this));
}
if (this.track) {
this.track.addEventListener('click', this.handleTrackClick.bind(this));
}
}
disconnectedCallback() {
if (this.thumb) {
this.thumb.removeEventListener('keydown', this.handleKeyDown.bind(this));
this.thumb.removeEventListener('pointerdown', this.handlePointerDown.bind(this));
this.thumb.removeEventListener('pointermove', this.handlePointerMove.bind(this));
this.thumb.removeEventListener('pointerup', this.handlePointerUp.bind(this));
}
if (this.track) {
this.track.removeEventListener('click', this.handleTrackClick.bind(this));
}
}
private get min(): number {
return Number(this.dataset.min) || 0;
}
private get max(): number {
return Number(this.dataset.max) || 100;
}
private get step(): number {
return Number(this.dataset.step) || 1;
}
private get largeStep(): number {
return Number(this.dataset.largeStep) || this.step * 10;
}
private get isVertical(): boolean {
return this.dataset.orientation === 'vertical';
}
private get isDisabled(): boolean {
return this.dataset.disabled === 'true';
}
private get format(): string | undefined {
return this.dataset.format;
}
private formatValue(value: number): string {
const fmt = this.format;
if (!fmt) return String(value);
return fmt
.replace('{value}', String(value))
.replace('{min}', String(this.min))
.replace('{max}', String(this.max));
}
private get currentValue(): number {
return Number(this.thumb?.getAttribute('aria-valuenow')) || this.min;
}
private clamp(val: number): number {
return Math.min(this.max, Math.max(this.min, val));
}
private roundToStep(val: number): number {
const steps = Math.round((val - this.min) / this.step);
const result = this.min + steps * this.step;
const decimalPlaces = (this.step.toString().split('.')[1] || '').length;
return Number(result.toFixed(decimalPlaces));
}
private updateValue(newValue: number) {
if (!this.thumb || this.isDisabled) return;
const clampedValue = this.clamp(this.roundToStep(newValue));
const currentValue = this.currentValue;
if (clampedValue === currentValue) return;
// Update ARIA
this.thumb.setAttribute('aria-valuenow', String(clampedValue));
// Update aria-valuetext if format is provided
const formattedValue = this.formatValue(clampedValue);
if (this.format) {
this.thumb.setAttribute('aria-valuetext', formattedValue);
}
// Update visual via CSS custom property
const percentage = ((clampedValue - this.min) / (this.max - this.min)) * 100;
if (this.track) {
this.track.style.setProperty('--slider-position', `${percentage}%`);
}
if (this.valueDisplay) {
this.valueDisplay.textContent = formattedValue;
}
// Dispatch event
this.dispatchEvent(
new CustomEvent('valuechange', {
detail: { value: clampedValue },
bubbles: true,
})
);
}
private getValueFromPointer(clientX: number, clientY: number): number {
if (!this.track) return this.currentValue;
const rect = this.track.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
return this.currentValue;
}
let percent: number;
if (this.isVertical) {
if (rect.height === 0) return this.currentValue;
percent = 1 - (clientY - rect.top) / rect.height;
} else {
if (rect.width === 0) return this.currentValue;
percent = (clientX - rect.left) / rect.width;
}
return this.min + percent * (this.max - this.min);
}
private handleKeyDown(event: KeyboardEvent) {
if (this.isDisabled) return;
let newValue = this.currentValue;
switch (event.key) {
case 'ArrowRight':
case 'ArrowUp':
newValue = this.currentValue + this.step;
break;
case 'ArrowLeft':
case 'ArrowDown':
newValue = this.currentValue - this.step;
break;
case 'Home':
newValue = this.min;
break;
case 'End':
newValue = this.max;
break;
case 'PageUp':
newValue = this.currentValue + this.largeStep;
break;
case 'PageDown':
newValue = this.currentValue - this.largeStep;
break;
default:
return;
}
event.preventDefault();
this.updateValue(newValue);
}
private handlePointerDown(event: PointerEvent) {
if (this.isDisabled || !this.thumb) return;
event.preventDefault();
if (typeof this.thumb.setPointerCapture === 'function') {
this.thumb.setPointerCapture(event.pointerId);
}
this.isDragging = true;
this.thumb.focus();
const newValue = this.getValueFromPointer(event.clientX, event.clientY);
this.updateValue(newValue);
}
private handlePointerMove(event: PointerEvent) {
if (!this.thumb) return;
const hasCapture =
typeof this.thumb.hasPointerCapture === 'function'
? this.thumb.hasPointerCapture(event.pointerId)
: this.isDragging;
if (!hasCapture) return;
const newValue = this.getValueFromPointer(event.clientX, event.clientY);
this.updateValue(newValue);
}
private handlePointerUp(event: PointerEvent) {
if (this.thumb && typeof this.thumb.releasePointerCapture === 'function') {
try {
this.thumb.releasePointerCapture(event.pointerId);
} catch {
// Ignore
}
}
this.isDragging = false;
}
private handleTrackClick(event: MouseEvent) {
if (this.isDisabled || event.target === this.thumb) return;
const newValue = this.getValueFromPointer(event.clientX, event.clientY);
this.updateValue(newValue);
this.thumb?.focus();
}
// Public method to update value programmatically
setValue(newValue: number) {
this.updateValue(newValue);
}
}
if (!customElements.get('apg-slider')) {
customElements.define('apg-slider', ApgSlider);
}
</script>使い方
Example
---
import Slider from './Slider.astro';
---
<!-- 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"
/>
<!-- Dynamic updates via Web Component API -->
<Slider defaultValue={50} id="my-slider" label="Volume" />
<script>
const slider = document.querySelector('#my-slider').closest('apg-slider');
slider.setValue(75);
slider.addEventListener('valuechange', (e) => {
console.log('Value changed:', e.detail.value);
});
</script>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}") |
アクセシビリティのために、
label、aria-label、またはaria-labelledbyのいずれかが必要です。Custom Events
| イベント | Detail | 説明 |
|---|---|---|
valuechange | { value: number } | キーボード、ポインター、またはsetValue()による値変更時に発火 |
テスト
テストは、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)です。
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 });
};
// Wait for hydration to complete: Astro removes the `ssr` attribute from
// each <astro-island> once its component has hydrated. (aria-valuenow is
// already present in the SSR HTML, so it cannot be used as a signal.)
const waitForHydration = async (page: import('@playwright/test').Page) => {
await expect(page.locator('astro-island[ssr]')).toHaveCount(0);
};
// ============================================
// 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();
await waitForHydration(page);
});
// ------------------------------------------
// 🔴 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');
});
test('ArrowRight increases a decimal step value without floating-point error', async ({
page,
}) => {
const slider = getSliderByLabel(page, 'Opacity');
await slider.focus();
await expect(slider).toHaveAttribute('aria-valuenow', '0.5');
await slider.press('ArrowRight');
await expect(slider).toHaveAttribute('aria-valuenow', '0.6');
});
});
// ------------------------------------------
// 🔴 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([]);
});
});
});
}
// ============================================
// Astro-specific: valuechange CustomEvent
// ============================================
test.describe('Slider (astro) - valuechange event', () => {
test.beforeEach(async ({ page }) => {
await page.goto('patterns/slider/astro/demo/');
await getSlider(page).first().waitFor();
await page.evaluate(() => {
const events: number[] = [];
(window as unknown as { __valueChanges: number[] }).__valueChanges = events;
document.addEventListener('valuechange', (e) => {
events.push((e as CustomEvent<{ value: number }>).detail.value);
});
});
});
test('dispatches valuechange with the new value on ArrowRight', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.focus();
await slider.press('ArrowRight');
const events = await page.evaluate(
() => (window as unknown as { __valueChanges: number[] }).__valueChanges
);
expect(events).toEqual([51]);
});
test('does not dispatch valuechange when the value does not change', async ({ page }) => {
const slider = getSliderByLabel(page, 'Volume');
await slider.focus();
await slider.press('End');
await slider.press('ArrowRight');
const events = await page.evaluate(
() => (window as unknown as { __valueChanges: number[] }).__valueChanges
);
expect(events).toEqual([100]);
});
});
// ============================================
// 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();
await waitForHydration(page);
// 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();
await waitForHydration(page);
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)- アクセシビリティテストエンジン
詳細はテスト戦略ガイドを参照してください。
Slider.test.astro.ts
/**
* Slider Astro Component Tests using Container API
*
* These tests verify the initial server-rendered HTML of Slider.astro.
* Web Component behavior (keyboard, pointer, value/valuetext updates and the
* valuechange event) is covered by E2E tests in e2e/slider.spec.ts.
*
* @see https://docs.astro.build/en/reference/container-reference/
*/
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Slider from './Slider.astro';
describe('Slider (Astro Container API)', () => {
let container: AstroContainer;
beforeEach(async () => {
container = await AstroContainer.create();
});
async function renderSlider(props: Record<string, unknown> = {}): Promise<Document> {
const html = await container.renderToString(Slider, { props });
return new JSDOM(html).window.document;
}
// 🔴 High Priority: HTML Structure
describe('HTML Structure', () => {
it('renders apg-slider custom element wrapper', async () => {
const doc = await renderSlider({ 'aria-label': 'Volume' });
expect(doc.querySelector('apg-slider')).not.toBeNull();
});
it('passes range settings to the Web Component via data attributes', async () => {
const doc = await renderSlider({ min: 10, max: 50, step: 2, 'aria-label': 'Volume' });
const element = doc.querySelector('apg-slider');
expect(element?.getAttribute('data-min')).toBe('10');
expect(element?.getAttribute('data-max')).toBe('50');
expect(element?.getAttribute('data-step')).toBe('2');
});
it('sets data-large-step to step * 10 when largeStep is not provided', async () => {
const doc = await renderSlider({ step: 2, 'aria-label': 'Volume' });
expect(doc.querySelector('apg-slider')?.getAttribute('data-large-step')).toBe('20');
});
it('sets data-large-step to largeStep when provided', async () => {
const doc = await renderSlider({ largeStep: 5, 'aria-label': 'Volume' });
expect(doc.querySelector('apg-slider')?.getAttribute('data-large-step')).toBe('5');
});
it('sets data-format when format is provided', async () => {
const doc = await renderSlider({ format: '{value}%', 'aria-label': 'Volume' });
expect(doc.querySelector('apg-slider')?.getAttribute('data-format')).toBe('{value}%');
});
it('sets the track position custom property from the initial value', async () => {
const doc = await renderSlider({ defaultValue: 25, 'aria-label': 'Volume' });
const track = doc.querySelector<HTMLElement>('.apg-slider-track');
expect(track?.getAttribute('style')).toBe('--slider-position: 25%');
});
it('adds the vertical modifier class for vertical orientation', async () => {
const doc = await renderSlider({ orientation: 'vertical', 'aria-label': 'Volume' });
expect(doc.querySelector('.apg-slider')?.classList.contains('apg-slider--vertical')).toBe(
true
);
});
it('hides the fill element from assistive technology', async () => {
const doc = await renderSlider({ 'aria-label': 'Volume' });
expect(doc.querySelector('.apg-slider-fill')?.getAttribute('aria-hidden')).toBe('true');
});
});
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('renders an element with role="slider"', async () => {
const doc = await renderSlider({ 'aria-label': 'Volume' });
expect(doc.querySelector('[role="slider"]')).not.toBeNull();
});
it('has aria-valuenow set to defaultValue', async () => {
const doc = await renderSlider({ defaultValue: 50, 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuenow')).toBe('50');
});
it('has aria-valuenow set to min when no defaultValue is given', async () => {
const doc = await renderSlider({ min: 10, 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuenow')).toBe('10');
});
it('has aria-valuemin="0" and aria-valuemax="100" by default', async () => {
const doc = await renderSlider({ 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuemin')).toBe('0');
expect(slider?.getAttribute('aria-valuemax')).toBe('100');
});
it('has aria-valuemin and aria-valuemax set to the provided range', async () => {
const doc = await renderSlider({ min: 1, max: 5, 'aria-label': 'Rating' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuemin')).toBe('1');
expect(slider?.getAttribute('aria-valuemax')).toBe('5');
});
it('has aria-valuetext when valueText is provided', async () => {
const doc = await renderSlider({
defaultValue: 75,
valueText: '75 percent',
'aria-label': 'Volume',
});
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuetext')).toBe('75 percent');
});
it('has aria-valuetext built from format with value, min and max', async () => {
const doc = await renderSlider({
defaultValue: 3,
min: 1,
max: 5,
format: '{value} of {max}',
'aria-label': 'Rating',
});
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuetext')).toBe('3 of 5');
});
it('does not have aria-valuetext when neither valueText nor format is provided', async () => {
const doc = await renderSlider({ defaultValue: 75, 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.hasAttribute('aria-valuetext')).toBe(false);
});
it('has aria-disabled="true" when disabled', async () => {
const doc = await renderSlider({ disabled: true, 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-disabled')).toBe('true');
});
it('does not have aria-disabled when not disabled', async () => {
const doc = await renderSlider({ 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.hasAttribute('aria-disabled')).toBe(false);
});
it('has aria-orientation="vertical" for a vertical slider', async () => {
const doc = await renderSlider({ orientation: 'vertical', 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-orientation')).toBe('vertical');
});
it('does not have aria-orientation for a horizontal slider', async () => {
const doc = await renderSlider({ orientation: 'horizontal', 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.hasAttribute('aria-orientation')).toBe(false);
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has aria-label when provided', async () => {
const doc = await renderSlider({ 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-label')).toBe('Volume');
});
it('has aria-labelledby when provided', async () => {
const doc = await renderSlider({ 'aria-labelledby': 'external-label' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-labelledby')).toBe('external-label');
});
it('references the visible label via aria-labelledby when label is provided', async () => {
const doc = await renderSlider({ label: 'Brightness' });
const slider = doc.querySelector('[role="slider"]');
const labelId = slider?.getAttribute('aria-labelledby') ?? '';
expect(doc.getElementById(labelId)?.textContent?.trim()).toBe('Brightness');
});
it('does not set aria-label when a visible label is provided', async () => {
const doc = await renderSlider({ label: 'Brightness', 'aria-label': 'Ignored' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.hasAttribute('aria-label')).toBe(false);
});
it('has aria-describedby when provided', async () => {
const doc = await renderSlider({ 'aria-label': 'Volume', 'aria-describedby': 'volume-help' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-describedby')).toBe('volume-help');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" on the thumb', async () => {
const doc = await renderSlider({ 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('tabindex')).toBe('0');
});
it('has tabindex="-1" on the thumb when disabled', async () => {
const doc = await renderSlider({ disabled: true, 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('tabindex')).toBe('-1');
});
});
// 🟡 Medium Priority: Initial Value Normalization
describe('Initial Value Normalization', () => {
it('clamps defaultValue below min to min', async () => {
const doc = await renderSlider({
defaultValue: -10,
min: 0,
max: 100,
'aria-label': 'Volume',
});
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuenow')).toBe('0');
});
it('clamps defaultValue above max to max', async () => {
const doc = await renderSlider({
defaultValue: 150,
min: 0,
max: 100,
'aria-label': 'Volume',
});
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuenow')).toBe('100');
});
it('rounds defaultValue to the nearest step', async () => {
const doc = await renderSlider({ defaultValue: 53, step: 5, 'aria-label': 'Volume' });
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuenow')).toBe('55');
});
it('rounds a decimal defaultValue without floating-point error', async () => {
const doc = await renderSlider({
defaultValue: 0.1 + 0.2,
min: 0,
max: 1,
step: 0.1,
'aria-label': 'Opacity',
});
const slider = doc.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuenow')).toBe('0.3');
});
});
// 🟡 Medium Priority: Visual Display
describe('Visual Display', () => {
it('shows the value when showValue is true', async () => {
const doc = await renderSlider({ defaultValue: 75, showValue: true, 'aria-label': 'Volume' });
expect(doc.querySelector('.apg-slider-value')?.textContent?.trim()).toBe('75');
});
it('shows the formatted value when format is provided', async () => {
const doc = await renderSlider({
defaultValue: 75,
format: '{value}%',
'aria-label': 'Volume',
});
expect(doc.querySelector('.apg-slider-value')?.textContent?.trim()).toBe('75%');
});
it('hides the value display from assistive technology', async () => {
const doc = await renderSlider({ defaultValue: 75, 'aria-label': 'Volume' });
expect(doc.querySelector('.apg-slider-value')?.getAttribute('aria-hidden')).toBe('true');
});
it('does not render the value when showValue is false', async () => {
const doc = await renderSlider({
defaultValue: 75,
showValue: false,
'aria-label': 'Volume',
});
expect(doc.querySelector('.apg-slider-value')).toBeNull();
});
it('renders the visible label text when label is provided', async () => {
const doc = await renderSlider({ label: 'Volume' });
expect(doc.querySelector('.apg-slider-label')?.textContent?.trim()).toBe('Volume');
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attributes', () => {
it('sets the id attribute on the thumb', async () => {
const doc = await renderSlider({ id: 'my-slider', 'aria-label': 'Volume' });
expect(doc.querySelector('[role="slider"]')?.id).toBe('my-slider');
});
it('applies a custom class to the container', async () => {
const doc = await renderSlider({ class: 'custom-class', 'aria-label': 'Volume' });
expect(doc.querySelector('.apg-slider')?.classList.contains('custom-class')).toBe(true);
});
});
});リソース
- 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