Slider
ユーザーが範囲内から値を選択できるインタラクティブなコントロール。
🤖 AI Implementation Guideデモ
ネイティブ 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 (必須)
スライダーの現在の数値を示します。ユーザーが値を変更すると動的に更新されます。
| 型 | 数値 |
| 必須 | はい |
| 範囲 | 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ハイコントラストモードでのアクセシビリティのためにシステムカラーを使用します
参考資料
ソースコード
---
/**
* 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> 使い方
---
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のいずれかが必要です。
Web Componentメソッド
| メソッド | 説明 |
|---|---|
setValue(value: number) | プログラムでスライダーの値を更新 |
カスタムイベント
| イベント | 詳細 |
|---|---|
valuechange | { value: number } - キーボード、ポインター、または
setValue()による値変更時に発火
|
テスト
テストは、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)
Slider.test.astro.ts /**
* Slider Web Component Tests
*
* Unit tests for the Web Component class.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('Slider (Web Component)', () => {
let container: HTMLElement;
// Web Component class extracted for testing
class TestApgSlider extends HTMLElement {
private thumb: HTMLElement | null = null;
private track: HTMLElement | null = null;
private fill: 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.fill = this.querySelector('.apg-slider-fill');
this.valueDisplay = this.querySelector('.apg-slider-value');
if (this.thumb) {
this.thumb.addEventListener('keydown', this.handleKeyDown.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 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;
this.thumb.setAttribute('aria-valuenow', String(clampedValue));
const percentage = ((clampedValue - this.min) / (this.max - this.min)) * 100;
if (this.fill) {
this.fill.style[this.isVertical ? 'height' : 'width'] = `${percentage}%`;
}
this.thumb.style[this.isVertical ? 'bottom' : 'left'] = `${percentage}%`;
if (this.valueDisplay) {
this.valueDisplay.textContent = String(clampedValue);
}
this.dispatchEvent(
new CustomEvent('valuechange', {
detail: { value: clampedValue },
bubbles: true,
})
);
}
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);
}
setValue(newValue: number) {
this.updateValue(newValue);
}
// Expose for testing
get _thumb() {
return this.thumb;
}
get _fill() {
return this.fill;
}
get _valueDisplay() {
return this.valueDisplay;
}
}
function createSliderHTML(
options: {
defaultValue?: number;
min?: number;
max?: number;
step?: number;
largeStep?: number;
orientation?: 'horizontal' | 'vertical';
disabled?: boolean;
showValue?: boolean;
label?: string;
valueText?: string;
id?: string;
ariaLabel?: string;
ariaLabelledby?: string;
} = {}
) {
const {
defaultValue,
min = 0,
max = 100,
step = 1,
largeStep,
orientation = 'horizontal',
disabled = false,
showValue = true,
label,
valueText,
id,
ariaLabel = 'Volume',
ariaLabelledby,
} = options;
// Calculate initial value
const clamp = (val: number, minVal: number, maxVal: number) =>
Math.min(maxVal, Math.max(minVal, val));
const roundToStep = (val: number, stepVal: number, minVal: number) => {
const steps = Math.round((val - minVal) / stepVal);
return minVal + steps * stepVal;
};
const initialValue = clamp(roundToStep(defaultValue ?? min, step, min), min, max);
const percentage = max === min ? 0 : ((initialValue - min) / (max - min)) * 100;
const isVertical = orientation === 'vertical';
const effectiveLargeStep = largeStep ?? step * 10;
const labelId = label ? `slider-label-${Math.random().toString(36).slice(2, 9)}` : undefined;
return `
<apg-slider
data-min="${min}"
data-max="${max}"
data-step="${step}"
data-large-step="${effectiveLargeStep}"
data-orientation="${orientation}"
data-disabled="${disabled}"
>
<div class="apg-slider ${isVertical ? 'apg-slider--vertical' : ''} ${disabled ? 'apg-slider--disabled' : ''}">
${label ? `<span id="${labelId}" class="apg-slider-label">${label}</span>` : ''}
<div class="apg-slider-track">
<div
class="apg-slider-fill"
style="${isVertical ? `height: ${percentage}%` : `width: ${percentage}%`}"
aria-hidden="true"
></div>
<div
role="slider"
${id ? `id="${id}"` : ''}
tabindex="${disabled ? -1 : 0}"
aria-valuenow="${initialValue}"
aria-valuemin="${min}"
aria-valuemax="${max}"
${valueText ? `aria-valuetext="${valueText}"` : ''}
${ariaLabelledby ? `aria-labelledby="${ariaLabelledby}"` : label ? `aria-labelledby="${labelId}"` : `aria-label="${ariaLabel}"`}
${isVertical ? `aria-orientation="vertical"` : ''}
${disabled ? `aria-disabled="true"` : ''}
class="apg-slider-thumb"
style="${isVertical ? `bottom: ${percentage}%` : `left: ${percentage}%`}"
></div>
</div>
${showValue ? `<span class="apg-slider-value" aria-hidden="true">${initialValue}</span>` : ''}
</div>
</apg-slider>
`;
}
beforeEach(() => {
// Register custom element if not already registered
if (!customElements.get('apg-slider')) {
customElements.define('apg-slider', TestApgSlider);
}
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="slider"', () => {
container.innerHTML = createSliderHTML();
expect(container.querySelector('[role="slider"]')).not.toBeNull();
});
it('has aria-valuenow set to current value', () => {
container.innerHTML = createSliderHTML({ defaultValue: 50 });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuenow')).toBe('50');
});
it('has aria-valuenow set to min when no defaultValue', () => {
container.innerHTML = createSliderHTML({ min: 10 });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuenow')).toBe('10');
});
it('has aria-valuemin set', () => {
container.innerHTML = createSliderHTML({ min: 0 });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuemin')).toBe('0');
});
it('has aria-valuemax set', () => {
container.innerHTML = createSliderHTML({ max: 100 });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuemax')).toBe('100');
});
it('has aria-valuetext when valueText provided', () => {
container.innerHTML = createSliderHTML({ defaultValue: 75, valueText: '75 percent' });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuetext')).toBe('75 percent');
});
it('does not have aria-valuetext when not provided', () => {
container.innerHTML = createSliderHTML({ defaultValue: 75 });
const slider = container.querySelector('[role="slider"]');
expect(slider?.hasAttribute('aria-valuetext')).toBe(false);
});
it('has aria-disabled="true" when disabled', () => {
container.innerHTML = createSliderHTML({ disabled: true });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-disabled')).toBe('true');
});
it('has aria-orientation="vertical" for vertical slider', () => {
container.innerHTML = createSliderHTML({ orientation: 'vertical' });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-orientation')).toBe('vertical');
});
it('does not have aria-orientation for horizontal slider', () => {
container.innerHTML = createSliderHTML({ orientation: 'horizontal' });
const slider = container.querySelector('[role="slider"]');
expect(slider?.hasAttribute('aria-orientation')).toBe(false);
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has accessible name via aria-label', () => {
container.innerHTML = createSliderHTML({ ariaLabel: 'Volume' });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-label')).toBe('Volume');
});
it('has accessible name via aria-labelledby', () => {
container.innerHTML = createSliderHTML({ ariaLabelledby: 'external-label' });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-labelledby')).toBe('external-label');
});
it('has accessible name via visible label', () => {
container.innerHTML = createSliderHTML({ label: 'Brightness' });
const slider = container.querySelector('[role="slider"]');
expect(slider?.hasAttribute('aria-labelledby')).toBe(true);
expect(container.querySelector('.apg-slider-label')?.textContent).toBe('Brightness');
});
});
// 🔴 High Priority: Keyboard Interaction
describe('Keyboard Interaction', () => {
it('increases value by step on ArrowRight', () => {
container.innerHTML = createSliderHTML({ defaultValue: 50, step: 1 });
const sliderComponent = container.querySelector('apg-slider') as TestApgSlider;
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
slider.dispatchEvent(event);
expect(slider.getAttribute('aria-valuenow')).toBe('51');
});
it('decreases value by step on ArrowLeft', () => {
container.innerHTML = createSliderHTML({ defaultValue: 50, step: 1 });
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const event = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true });
slider.dispatchEvent(event);
expect(slider.getAttribute('aria-valuenow')).toBe('49');
});
it('sets min value on Home', () => {
container.innerHTML = createSliderHTML({ defaultValue: 50, min: 0 });
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const event = new KeyboardEvent('keydown', { key: 'Home', bubbles: true });
slider.dispatchEvent(event);
expect(slider.getAttribute('aria-valuenow')).toBe('0');
});
it('sets max value on End', () => {
container.innerHTML = createSliderHTML({ defaultValue: 50, max: 100 });
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const event = new KeyboardEvent('keydown', { key: 'End', bubbles: true });
slider.dispatchEvent(event);
expect(slider.getAttribute('aria-valuenow')).toBe('100');
});
it('increases value by large step on PageUp', () => {
container.innerHTML = createSliderHTML({ defaultValue: 50, step: 1 });
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const event = new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true });
slider.dispatchEvent(event);
expect(slider.getAttribute('aria-valuenow')).toBe('60');
});
it('decreases value by large step on PageDown', () => {
container.innerHTML = createSliderHTML({ defaultValue: 50, step: 1 });
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const event = new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true });
slider.dispatchEvent(event);
expect(slider.getAttribute('aria-valuenow')).toBe('40');
});
it('does not exceed max on ArrowRight', () => {
container.innerHTML = createSliderHTML({ defaultValue: 100, max: 100 });
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
slider.dispatchEvent(event);
expect(slider.getAttribute('aria-valuenow')).toBe('100');
});
it('does not go below min on ArrowLeft', () => {
container.innerHTML = createSliderHTML({ defaultValue: 0, min: 0 });
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const event = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true });
slider.dispatchEvent(event);
expect(slider.getAttribute('aria-valuenow')).toBe('0');
});
it('does not change value when disabled', () => {
container.innerHTML = createSliderHTML({ defaultValue: 50, disabled: true });
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
slider.dispatchEvent(event);
expect(slider.getAttribute('aria-valuenow')).toBe('50');
});
});
// 🔴 High Priority: Focus Management
describe('Focus Management', () => {
it('has tabindex="0" on thumb', () => {
container.innerHTML = createSliderHTML();
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('tabindex')).toBe('0');
});
it('has tabindex="-1" when disabled', () => {
container.innerHTML = createSliderHTML({ disabled: true });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('tabindex')).toBe('-1');
});
});
// 🟡 Medium Priority: Events
describe('Events', () => {
it('dispatches valuechange event on value change', () => {
container.innerHTML = createSliderHTML({ defaultValue: 50 });
const sliderComponent = container.querySelector('apg-slider') as TestApgSlider;
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const eventHandler = vi.fn();
sliderComponent.addEventListener('valuechange', eventHandler);
const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
slider.dispatchEvent(event);
expect(eventHandler).toHaveBeenCalled();
expect(eventHandler.mock.calls[0][0].detail.value).toBe(51);
});
it('does not dispatch event when value does not change', () => {
container.innerHTML = createSliderHTML({ defaultValue: 100, max: 100 });
const sliderComponent = container.querySelector('apg-slider') as TestApgSlider;
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const eventHandler = vi.fn();
sliderComponent.addEventListener('valuechange', eventHandler);
const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
slider.dispatchEvent(event);
expect(eventHandler).not.toHaveBeenCalled();
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('handles decimal step values correctly', () => {
container.innerHTML = createSliderHTML({ defaultValue: 0.5, min: 0, max: 1, step: 0.1 });
const slider = container.querySelector('[role="slider"]') as HTMLElement;
const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
slider.dispatchEvent(event);
expect(slider.getAttribute('aria-valuenow')).toBe('0.6');
});
it('clamps value to min/max', () => {
container.innerHTML = createSliderHTML({ defaultValue: -10, min: 0, max: 100 });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuenow')).toBe('0');
});
it('rounds value to step', () => {
container.innerHTML = createSliderHTML({ defaultValue: 53, min: 0, max: 100, step: 5 });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('aria-valuenow')).toBe('55');
});
});
// 🟡 Medium Priority: Visual Display
describe('Visual Display', () => {
it('shows value when showValue is true', () => {
container.innerHTML = createSliderHTML({ defaultValue: 75, showValue: true });
expect(container.querySelector('.apg-slider-value')?.textContent).toBe('75');
});
it('hides value when showValue is false', () => {
container.innerHTML = createSliderHTML({ defaultValue: 75, showValue: false });
expect(container.querySelector('.apg-slider-value')).toBeNull();
});
it('displays visible label when label provided', () => {
container.innerHTML = createSliderHTML({ label: 'Volume' });
expect(container.querySelector('.apg-slider-label')?.textContent).toBe('Volume');
});
});
// 🟢 Low Priority: HTML Attributes
describe('HTML Attributes', () => {
it('sets id attribute', () => {
container.innerHTML = createSliderHTML({ id: 'my-slider' });
const slider = container.querySelector('[role="slider"]');
expect(slider?.getAttribute('id')).toBe('my-slider');
});
});
});
リソース
-
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