APG Patterns
English
English

Slider

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

デモ

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 つまみ要素 ユーザーが範囲内から値を選択できるスライダーとして要素を識別します

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

プロパティ型デフォルト説明
defaultValuenumberminスライダーの初期値
minnumber0最小値
maxnumber100最大値
stepnumber1キーボードナビゲーションのステップ増分
largeStepnumberstep * 10PageUp/PageDownの大きなステップ
orientation'horizontal' | 'vertical''horizontal'スライダーの方向
disabledbooleanfalseスライダーが無効かどうか
showValuebooleantrue値のテキストを表示するかどうか
labelstring-表示ラベル(aria-labelledbyとしても使用)
valueTextstring-aria-valuetextの人間が読める値(静的)
formatstring-表示と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-labelaria-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なし
verticalaria-orientation="vertical"が設定される

中優先度: アクセシビリティ

テスト説明
axe violationsaxe-coreによるアクセシビリティ違反なし

中優先度: エッジケース

テスト説明
decimal values小数ステップ値を正しく処理
negative range負のmin/max範囲を処理
clamp to minmin未満のdefaultValueはminにクランプ
clamp to maxmax超過のdefaultValueはmaxにクランプ

中優先度: コールバック

テスト説明
onValueChange変更時に新しい値でコールバックが呼ばれる

低優先度: HTML属性継承

テスト説明
classNameカスタムクラスがコンテナに適用される
idID属性が正しく設定される
data-*データ属性が引き継がれる

低優先度: フレームワーク間の一貫性

テスト説明
All frameworks render slidersReact、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

テストツール

詳細はテスト戦略ガイドを参照してください。

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

リソース