APG Patterns
English GitHub
English GitHub

Slider

ユーザーが範囲内から値を選択できるインタラクティブなコントロール。

🤖 AI Implementation Guide

デモ

Volume
Progress
Rating
Vertical
Disabled

ネイティブ 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-valueminaria-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ハイコントラストモードでのアクセシビリティのためにシステムカラーを使用します

参考資料

ソースコード

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>

使い方

使用例
---
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}")

アクセシビリティのために、labelaria-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-* データ属性が渡される

テストツール

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');
    });
  });
});

リソース