APG Patterns
English
English

Slider

指定された範囲内から値を選択する入力。

デモ

Volume
Progress
Rating
Vertical
Disabled

デモのみ表示 →

Native HTML

ネイティブ HTML を優先

このカスタムコンポーネントを使用する前に、ネイティブの<input type="range">要素の使用を検討してください。組み込みのキーボードサポート、JavaScriptなしでの動作、ネイティブのアクセシビリティサポートを提供します。


          <label for="volume">Volume</label>
<input type="range" id="volume" min="0" max="100" value="50">
        

ネイティブ要素では提供できないカスタムスタイリングが必要な場合、またはインタラクション中に特定の視覚的フィードバックが必要な場合にのみ、カスタム実装を使用してください。

ユースケース ネイティブ HTML カスタム実装
基本的な値の選択 推奨 不要
キーボードサポート 組み込み 手動実装
JavaScript無効時のサポート ネイティブで動作 フォールバック必要
フォーム統合 組み込み 手動実装
カスタムスタイリング 限定的(疑似要素) 完全な制御
ブラウザ間で一貫した外観 大きく異なる 一貫性あり
垂直方向 限定的なブラウザサポート 完全な制御

ネイティブの<input type="range">のスタイリングは、ブラウザ間で一貫性がないことで知られています。スタイリングにはベンダー固有の疑似要素(::-webkit-slider-thumb::-moz-range-thumbなど)が必要で、メンテナンスが複雑になる可能性があります。

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
slider つまみ要素 ユーザーが範囲内から値を選択できるスライダーとして要素を識別します

slider ロールは、ユーザーがトラックに沿ってつまみを動かして値を選択するインタラクティブなコントロールに使用されます。 meter ロールとは異なり、スライダーはインタラクティブでキーボードフォーカスを受け取ります。

WAI-ARIA プロパティ

aria-valuenow (必須)

スライダーの現在の数値を示します。ユーザーが値を変更すると動的に更新されます。

Number
必須 はい
範囲 aria-valueminaria-valuemax の間である必要があります

aria-valuemin (必須)

スライダーの許容最小値を指定します。

Number
必須 はい
デフォルト 0

aria-valuemax (必須)

スライダーの許容最大値を指定します。

Number
必須 はい
デフォルト 100

aria-valuetext

現在の値の人間が読めるテキスト代替を提供します。数値だけでは十分な意味を伝えられない場合に使用します。

String
必須 いいえ(値にコンテキストが必要な場合は推奨)
"50%", "Medium", "3 of 5 stars"

aria-orientation

スライダーの向きを指定します。垂直スライダーの場合のみ "vertical" に設定します。水平の場合は省略します(デフォルト)。

"horizontal" | "vertical"
必須 いいえ
デフォルト horizontal(暗黙的)

aria-disabled

スライダーが無効でインタラクティブではないことを示します。

true | undefined
必須 いいえ

キーボードサポート

キー アクション
Right Arrow 値を1ステップ増加させる
Up Arrow 値を1ステップ増加させる
Left Arrow 値を1ステップ減少させる
Down Arrow 値を1ステップ減少させる
Home スライダーを最小値に設定する
End スライダーを最大値に設定する
Page Up 値を大きいステップで増加させる(デフォルト: step * 10)
Page Down 値を大きいステップで減少させる(デフォルト: step * 10)

アクセシブルな名前

スライダーはアクセシブルな名前を持つ必要があります。これは以下の方法で提供できます:

  • 表示されるラベル - label プロパティを使用して表示されるラベルを提供
  • aria-label - スライダーに見えないラベルを提供
  • aria-labelledby - 外部要素をラベルとして参照

ポインター操作

この実装はマウスとタッチ操作をサポートしています:

  • トラックをクリック - クリックした位置につまみを即座に移動
  • つまみをドラッグ - ドラッグ中の連続的な調整が可能
  • ポインターキャプチャ - ポインターがスライダーの外に出てもインタラクションを維持

ビジュアルデザイン

この実装は、アクセシブルなビジュアルデザインのためのWCAGガイドラインに従っています:

  • フォーカスインジケーター - つまみ要素に可視のフォーカスリング
  • 視覚的な塗りつぶし - 現在の値を比例的に表示
  • ホバー状態 - ホバー時の視覚的フィードバック
  • 無効状態 - スライダーが無効な時の明確な視覚的表示
  • 強制カラーモード - Windowsハイコントラストモードでのアクセシビリティのためにシステムカラーを使用

参考資料

ソースコード

Slider.tsx
import { clsx } from 'clsx';
import { useCallback, useId, useRef, useState } from 'react';

// Label: one of these required (exclusive)
type LabelProps =
  | { label: string; 'aria-label'?: never; 'aria-labelledby'?: never }
  | { label?: never; 'aria-label': string; 'aria-labelledby'?: never }
  | { label?: never; 'aria-label'?: never; 'aria-labelledby': string };

// ValueText: exclusive with format
type ValueTextProps =
  | { valueText: string; format?: never }
  | { valueText?: never; format?: string }
  | { valueText?: never; format?: never };

type SliderBaseProps = {
  defaultValue?: number;
  min?: number;
  max?: number;
  step?: number;
  largeStep?: number;
  orientation?: 'horizontal' | 'vertical';
  disabled?: boolean;
  showValue?: boolean;
  onValueChange?: (value: number) => void;
  className?: string;
  id?: string;
  'aria-describedby'?: string;
  'data-testid'?: string;
};

export type SliderProps = SliderBaseProps & LabelProps & ValueTextProps;

// Clamp value to min/max range
const clamp = (value: number, min: number, max: number): number => {
  return Math.min(max, Math.max(min, value));
};

// Round value to nearest step
const roundToStep = (value: number, step: number, min: number): number => {
  const steps = Math.round((value - min) / step);
  const result = min + steps * step;
  // Fix floating point precision issues
  const decimalPlaces = (step.toString().split('.')[1] || '').length;
  return Number(result.toFixed(decimalPlaces));
};

// Calculate percentage for visual position
const getPercent = (value: number, min: number, max: number): number => {
  if (max === min) return 0;
  return ((value - min) / (max - min)) * 100;
};

// Format value helper
const formatValueText = (
  value: number,
  formatStr: string | undefined,
  min: number,
  max: number
): string => {
  if (!formatStr) return String(value);
  return formatStr
    .replace('{value}', String(value))
    .replace('{min}', String(min))
    .replace('{max}', String(max));
};

export const Slider: React.FC<SliderProps> = ({
  defaultValue,
  min = 0,
  max = 100,
  step = 1,
  largeStep,
  orientation = 'horizontal',
  disabled = false,
  showValue = true,
  onValueChange,
  label,
  valueText,
  format,
  className,
  id,
  'aria-describedby': ariaDescribedby,
  'data-testid': dataTestId,
  ...rest
}) => {
  // Calculate initial value: defaultValue clamped and rounded to step
  const initialValue = clamp(roundToStep(defaultValue ?? min, step, min), min, max);
  const [value, setValue] = useState(initialValue);

  const thumbRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const labelId = useId();
  const isVertical = orientation === 'vertical';
  const effectiveLargeStep = largeStep ?? step * 10;

  // Update value and call callback
  const updateValue = useCallback(
    (newValue: number) => {
      const clampedValue = clamp(roundToStep(newValue, step, min), min, max);
      if (clampedValue !== value) {
        setValue(clampedValue);
        onValueChange?.(clampedValue);
      }
    },
    [value, step, min, max, onValueChange]
  );

  // Calculate value from pointer position
  const getValueFromPointer = useCallback(
    (clientX: number, clientY: number) => {
      const track = trackRef.current;
      if (!track) return value;

      const rect = track.getBoundingClientRect();

      // Guard against zero-size track (e.g., in jsdom tests)
      if (rect.width === 0 && rect.height === 0) {
        return value;
      }

      let percent: number;

      if (isVertical) {
        // Vertical: top = max, bottom = min
        if (rect.height === 0) return value;
        percent = 1 - (clientY - rect.top) / rect.height;
      } else {
        // Horizontal: left = min, right = max
        if (rect.width === 0) return value;
        percent = (clientX - rect.left) / rect.width;
      }

      const rawValue = min + percent * (max - min);
      return clamp(roundToStep(rawValue, step, min), min, max);
    },
    [isVertical, min, max, step, value]
  );

  // Keyboard handler
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      if (disabled) return;

      let newValue = value;

      switch (event.key) {
        case 'ArrowRight':
        case 'ArrowUp':
          newValue = value + step;
          break;
        case 'ArrowLeft':
        case 'ArrowDown':
          newValue = value - step;
          break;
        case 'Home':
          newValue = min;
          break;
        case 'End':
          newValue = max;
          break;
        case 'PageUp':
          newValue = value + effectiveLargeStep;
          break;
        case 'PageDown':
          newValue = value - effectiveLargeStep;
          break;
        default:
          return; // Don't prevent default for other keys
      }

      event.preventDefault();
      updateValue(newValue);
    },
    [value, step, min, max, effectiveLargeStep, disabled, updateValue]
  );

  // Track whether we're dragging (for environments without pointer capture support)
  const isDraggingRef = useRef(false);

  // Pointer handlers for drag
  const handlePointerDown = useCallback(
    (event: React.PointerEvent) => {
      if (disabled) return;

      event.preventDefault();
      const thumb = thumbRef.current;
      if (!thumb) return;

      // Use pointer capture if available
      if (typeof thumb.setPointerCapture === 'function') {
        thumb.setPointerCapture(event.pointerId);
      }
      isDraggingRef.current = true;
      thumb.focus();

      const newValue = getValueFromPointer(event.clientX, event.clientY);
      updateValue(newValue);
    },
    [disabled, getValueFromPointer, updateValue]
  );

  const handlePointerMove = useCallback(
    (event: React.PointerEvent) => {
      const thumb = thumbRef.current;
      if (!thumb) return;

      // Check pointer capture or fallback to dragging state
      const hasCapture =
        typeof thumb.hasPointerCapture === 'function'
          ? thumb.hasPointerCapture(event.pointerId)
          : isDraggingRef.current;

      if (!hasCapture) return;

      const newValue = getValueFromPointer(event.clientX, event.clientY);
      updateValue(newValue);
    },
    [getValueFromPointer, updateValue]
  );

  const handlePointerUp = useCallback((event: React.PointerEvent) => {
    const thumb = thumbRef.current;
    if (thumb && typeof thumb.releasePointerCapture === 'function') {
      try {
        thumb.releasePointerCapture(event.pointerId);
      } catch {
        // Ignore if pointer capture was not set
      }
    }
    isDraggingRef.current = false;
  }, []);

  // Track click handler
  const handleTrackClick = useCallback(
    (event: React.MouseEvent) => {
      if (disabled) return;

      // Ignore if already handled by thumb
      if (event.target === thumbRef.current) return;

      const newValue = getValueFromPointer(event.clientX, event.clientY);
      updateValue(newValue);
      thumbRef.current?.focus();
    },
    [disabled, getValueFromPointer, updateValue]
  );

  const percent = getPercent(value, min, max);

  // Determine aria-valuetext
  const ariaValueText =
    valueText ?? (format ? formatValueText(value, format, min, max) : undefined);

  // Determine display text
  const displayText = valueText ? valueText : formatValueText(value, format, min, max);

  // Determine aria-labelledby
  const ariaLabelledby = rest['aria-labelledby'] ?? (label ? labelId : undefined);

  return (
    <div
      className={clsx(
        'apg-slider',
        isVertical && 'apg-slider--vertical',
        disabled && 'apg-slider--disabled',
        className
      )}
    >
      {label && (
        <span id={labelId} className="apg-slider-label">
          {label}
        </span>
      )}
      {/* apg-slider-track capture child elements click events */}
      {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
      <div
        ref={trackRef}
        className="apg-slider-track"
        style={{ '--slider-position': `${percent}%` }}
        onClick={handleTrackClick}
      >
        <div className="apg-slider-fill" aria-hidden="true" />
        <div
          ref={thumbRef}
          role="slider"
          id={id}
          tabIndex={disabled ? -1 : 0}
          aria-valuenow={value}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuetext={ariaValueText}
          aria-label={rest['aria-label']}
          aria-labelledby={ariaLabelledby}
          aria-orientation={isVertical ? 'vertical' : undefined}
          aria-disabled={disabled ? true : undefined}
          aria-describedby={ariaDescribedby}
          data-testid={dataTestId}
          className="apg-slider-thumb"
          onKeyDown={handleKeyDown}
          onPointerDown={handlePointerDown}
          onPointerMove={handlePointerMove}
          onPointerUp={handlePointerUp}
        />
      </div>
      {showValue && (
        <span className="apg-slider-value" aria-hidden="true">
          {displayText}
        </span>
      )}
    </div>
  );
};

使い方

Example
import { Slider } from './Slider';

function App() {
  return (
    <div>
      {/* Basic usage with aria-label */}
      <Slider defaultValue={50} aria-label="Volume" />

      {/* With visible label */}
      <Slider defaultValue={50} label="Volume" />

      {/* With format for display and aria-valuetext */}
      <Slider
        defaultValue={75}
        label="Progress"
        format="{value}%"
      />

      {/* Custom range with step */}
      <Slider
        defaultValue={3}
        min={1}
        max={5}
        step={1}
        label="Rating"
        format="{value} of {max}"
      />

      {/* Vertical slider */}
      <Slider
        defaultValue={50}
        label="Volume"
        orientation="vertical"
      />

      {/* With callback */}
      <Slider
        defaultValue={50}
        label="Value"
        onValueChange={(value) => console.log(value)}
      />
    </div>
  );
}

API

プロパティ デフォルト 説明
defaultValue number min スライダーの初期値
min number 0 最小値
max number 100 最大値
step number 1 キーボードナビゲーションのステップ増分
largeStep number step * 10 PageUp/PageDownの大きなステップ
orientation 'horizontal' | 'vertical' 'horizontal' スライダーの向き
disabled boolean false スライダーが無効化されているかどうか
showValue boolean true 値のテキストを表示するかどうか
label string - 表示されるラベル(aria-labelledbyとしても使用)
valueText string - aria-valuetextの人間が読める値
format string - 表示とaria-valuetextのフォーマットパターン(例:"{value}%"、"{value} of {max}")
onValueChange (value: number) => void - 値が変更されたときのコールバック

アクセシビリティのため、labelaria-label、またはaria-labelledbyのいずれかが必要です。

テスト

テストは、ARIA属性、キーボードインタラクション、ポインター操作、アクセシビリティ要件のAPG準拠を検証します。

テストカテゴリ

高優先度: ARIA 属性

テスト 説明
role="slider" 要素にsliderロールが設定されている
aria-valuenow 現在値が正しく設定・更新される
aria-valuemin 最小値が設定されている
aria-valuemax 最大値が設定されている
aria-valuetext 提供された場合、人間が読めるテキストが設定される
aria-disabled 無効状態が設定時に反映される

高優先度: アクセシブルな名前

テスト 説明
aria-label aria-label属性経由のアクセシブルな名前
aria-labelledby 外部要素参照経由のアクセシブルな名前
visible label 表示ラベルがアクセシブルな名前を提供

高優先度: キーボードインタラクション

テスト 説明
Arrow Right/Up 値を1ステップ増加
Arrow Left/Down 値を1ステップ減少
Home 値を最小値に設定
End 値を最大値に設定
Page Up/Down 値を大きいステップで増加/減少
Boundary clamping 値がmin/max制限を超えない
Disabled state 無効時はキーボード操作が無効

高優先度: フォーカス管理

テスト 説明
tabindex="0" つまみがフォーカス可能
tabindex="-1" 無効時はつまみがフォーカス不可

高優先度: 向き

テスト 説明
horizontal 水平スライダーにはaria-orientationなし
vertical aria-orientation="vertical"が設定される

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

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

中優先度: エッジケース

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

中優先度: コールバック

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

低優先度: HTML属性継承

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

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

テスト 説明
All frameworks render sliders React、Vue、Svelte、Astroすべてがスライダー要素をレンダリング
Consistent ARIA attributes すべてのフレームワークで一貫したaria-valuenow、aria-valuemin、aria-valuemax
Keyboard navigation すべてのフレームワークが矢印キー、Home、Endキーボードナビゲーションをサポート

テストコード例

以下は実際のE2Eテストファイル(e2e/slider.spec.ts)です。

e2e/slider.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Slider Pattern
 *
 * An interactive control that allows users to select a value from within a range.
 * Uses role="slider" with aria-valuenow, aria-valuemin, and aria-valuemax.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/slider/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// ============================================
// Helper Functions
// ============================================

const getSlider = (page: import('@playwright/test').Page) => {
  return page.getByRole('slider');
};

const getSliderByLabel = (page: import('@playwright/test').Page, label: string) => {
  return page.getByRole('slider', { name: label });
};

// ============================================
// Framework-specific Tests
// ============================================

for (const framework of frameworks) {
  test.describe(`Slider (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/slider/${framework}/demo/`);
      await getSlider(page).first().waitFor();

      // Wait for hydration - slider should have aria-valuenow
      const firstSlider = getSlider(page).first();
      await expect
        .poll(async () => {
          const valuenow = await firstSlider.getAttribute('aria-valuenow');
          return valuenow !== null;
        })
        .toBe(true);
    });

    // ------------------------------------------
    // 🔴 High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('slider has role="slider"', async ({ page }) => {
        const slider = getSlider(page).first();
        await expect(slider).toHaveRole('slider');
      });

      test('slider has aria-valuenow', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        const valuenow = await slider.getAttribute('aria-valuenow');
        expect(valuenow).toBe('50');
      });

      test('slider has aria-valuemin', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        const valuemin = await slider.getAttribute('aria-valuemin');
        expect(valuemin).toBe('0');
      });

      test('slider has aria-valuemax', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        const valuemax = await slider.getAttribute('aria-valuemax');
        expect(valuemax).toBe('100');
      });

      test('slider with custom range has correct min/max', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Rating');
        await expect(slider).toHaveAttribute('aria-valuemin', '1');
        await expect(slider).toHaveAttribute('aria-valuemax', '5');
        await expect(slider).toHaveAttribute('aria-valuenow', '3');
      });

      test('slider has accessible name via label', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await expect(slider).toBeVisible();
      });

      test('slider has aria-valuetext when format is provided', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await expect(slider).toHaveAttribute('aria-valuetext', '50%');
      });

      test('rating slider has descriptive aria-valuetext', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Rating');
        await expect(slider).toHaveAttribute('aria-valuetext', '3 of 5');
      });

      test('vertical slider has aria-orientation="vertical"', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Vertical');
        await expect(slider).toHaveAttribute('aria-orientation', 'vertical');
      });

      test('disabled slider has aria-disabled="true"', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Disabled');
        await expect(slider).toHaveAttribute('aria-disabled', 'true');
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Interaction
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction', () => {
      test('ArrowRight increases value by step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.press('ArrowRight');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) + 1);
      });

      test('ArrowLeft decreases value by step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.press('ArrowLeft');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) - 1);
      });

      test('ArrowUp increases value by step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.press('ArrowUp');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) + 1);
      });

      test('ArrowDown decreases value by step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.press('ArrowDown');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) - 1);
      });

      test('Home sets value to minimum', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        await slider.press('Home');

        await expect(slider).toHaveAttribute('aria-valuenow', '0');
      });

      test('End sets value to maximum', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        await slider.press('End');

        await expect(slider).toHaveAttribute('aria-valuenow', '100');
      });

      test('PageUp increases value by large step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.press('PageUp');

        const newValue = await slider.getAttribute('aria-valuenow');
        // Large step is typically step * 10 = 10
        expect(Number(newValue)).toBe(Number(initialValue) + 10);
      });

      test('PageDown decreases value by large step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.press('PageDown');

        const newValue = await slider.getAttribute('aria-valuenow');
        // Large step is typically step * 10 = 10
        expect(Number(newValue)).toBe(Number(initialValue) - 10);
      });

      test('value does not exceed maximum', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        // Set to max first
        await slider.press('End');
        await expect(slider).toHaveAttribute('aria-valuenow', '100');

        // Try to go beyond max
        await slider.press('ArrowRight');
        await expect(slider).toHaveAttribute('aria-valuenow', '100');
      });

      test('value does not go below minimum', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        // Set to min first
        await slider.press('Home');
        await expect(slider).toHaveAttribute('aria-valuenow', '0');

        // Try to go below min
        await slider.press('ArrowLeft');
        await expect(slider).toHaveAttribute('aria-valuenow', '0');
      });

      test('Rating slider respects step of 1', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Rating');
        await slider.click();
        await expect(slider).toBeFocused();

        // Initial value might change due to click position, so use Home first
        await slider.press('Home');
        await expect(slider).toHaveAttribute('aria-valuenow', '1');

        await slider.press('ArrowRight');
        await expect(slider).toHaveAttribute('aria-valuenow', '2');

        await slider.press('End');
        await expect(slider).toHaveAttribute('aria-valuenow', '5');

        // Should not exceed max
        await slider.press('ArrowRight');
        await expect(slider).toHaveAttribute('aria-valuenow', '5');
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Vertical Slider Keyboard
    // ------------------------------------------
    test.describe('APG: Vertical Slider Keyboard', () => {
      test('ArrowUp increases value in vertical slider', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Vertical');
        await slider.click();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.press('ArrowUp');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) + 1);
      });

      test('ArrowDown decreases value in vertical slider', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Vertical');
        await slider.click();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.press('ArrowDown');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) - 1);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Focus Management
    // ------------------------------------------
    test.describe('APG: Focus Management', () => {
      test('slider is focusable', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();
      });

      test('slider has tabindex="0"', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await expect(slider).toHaveAttribute('tabindex', '0');
      });

      test('disabled slider has tabindex="-1"', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Disabled');
        await expect(slider).toHaveAttribute('tabindex', '-1');
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Disabled State
    // ------------------------------------------
    test.describe('Disabled State', () => {
      test('disabled slider does not change value on ArrowRight', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Disabled');

        // Force focus via JavaScript (click won't work on disabled)
        await slider.evaluate((el) => (el as HTMLElement).focus());

        const initialValue = await slider.getAttribute('aria-valuenow');
        await page.keyboard.press('ArrowRight');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(newValue).toBe(initialValue);
      });

      test('disabled slider does not change value on Home', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Disabled');

        // Force focus via JavaScript
        await slider.evaluate((el) => (el as HTMLElement).focus());

        await page.keyboard.press('Home');

        // Should still be 50 (default value)
        await expect(slider).toHaveAttribute('aria-valuenow', '50');
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: aria-valuetext Updates
    // ------------------------------------------
    test.describe('aria-valuetext Updates', () => {
      test('aria-valuetext updates on value change', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        // Use Home to reset to known value
        await slider.press('Home');
        await expect(slider).toHaveAttribute('aria-valuetext', '0%');

        await slider.press('ArrowRight');
        await expect(slider).toHaveAttribute('aria-valuetext', '1%');
      });

      test('Rating slider aria-valuetext updates correctly', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Rating');
        await slider.click();
        await expect(slider).toBeFocused();

        // Use Home to reset to known value (min=1)
        await slider.press('Home');
        await expect(slider).toHaveAttribute('aria-valuetext', '1 of 5');

        await slider.press('ArrowRight');
        await expect(slider).toHaveAttribute('aria-valuetext', '2 of 5');
      });
    });

    // ------------------------------------------
    // 🟢 Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        const results = await new AxeBuilder({ page }).include('[role="slider"]').analyze();

        expect(results.violations).toEqual([]);
      });

      test('vertical slider has no axe-core violations', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Vertical');
        await slider.scrollIntoViewIfNeeded();

        const results = await new AxeBuilder({ page })
          .include('[aria-orientation="vertical"]')
          .analyze();

        expect(results.violations).toEqual([]);
      });

      test('disabled slider has no axe-core violations', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Disabled');
        await slider.scrollIntoViewIfNeeded();

        const results = await new AxeBuilder({ page }).include('[aria-disabled="true"]').analyze();

        expect(results.violations).toEqual([]);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('Slider - Cross-framework Consistency', () => {
  test('all frameworks render sliders', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/slider/${framework}/demo/`);
      await getSlider(page).first().waitFor();

      const sliders = getSlider(page);
      const count = await sliders.count();
      expect(count).toBeGreaterThanOrEqual(4); // Volume, Rating, Vertical, Disabled
    }
  });

  test('all frameworks have consistent ARIA attributes', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/slider/${framework}/demo/`);
      await getSlider(page).first().waitFor();

      // Wait for hydration
      await expect
        .poll(async () => {
          const valuenow = await getSlider(page).first().getAttribute('aria-valuenow');
          return valuenow !== null;
        })
        .toBe(true);

      // Check Volume slider
      const volumeSlider = getSliderByLabel(page, 'Volume');
      await expect(volumeSlider).toHaveAttribute('aria-valuenow', '50');
      await expect(volumeSlider).toHaveAttribute('aria-valuemin', '0');
      await expect(volumeSlider).toHaveAttribute('aria-valuemax', '100');
      await expect(volumeSlider).toHaveAttribute('aria-valuetext', '50%');

      // Check Rating slider
      const ratingSlider = getSliderByLabel(page, 'Rating');
      await expect(ratingSlider).toHaveAttribute('aria-valuenow', '3');
      await expect(ratingSlider).toHaveAttribute('aria-valuemin', '1');
      await expect(ratingSlider).toHaveAttribute('aria-valuemax', '5');

      // Check Vertical slider
      const verticalSlider = getSliderByLabel(page, 'Vertical');
      await expect(verticalSlider).toHaveAttribute('aria-orientation', 'vertical');

      // Check Disabled slider
      const disabledSlider = getSliderByLabel(page, 'Disabled');
      await expect(disabledSlider).toHaveAttribute('aria-disabled', 'true');
    }
  });

  test('all frameworks support keyboard navigation', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/slider/${framework}/demo/`);
      await getSlider(page).first().waitFor();

      // Wait for hydration
      await expect
        .poll(async () => {
          const valuenow = await getSlider(page).first().getAttribute('aria-valuenow');
          return valuenow !== null;
        })
        .toBe(true);

      const slider = getSliderByLabel(page, 'Volume');
      await slider.click();
      await expect(slider).toBeFocused();

      // Test Home (to reset to known value after click)
      await slider.press('Home');
      await expect(slider).toHaveAttribute('aria-valuenow', '0');

      // Test ArrowRight
      await slider.press('ArrowRight');
      await expect(slider).toHaveAttribute('aria-valuenow', '1');

      // Test End
      await slider.press('End');
      await expect(slider).toHaveAttribute('aria-valuenow', '100');
    }
  });
});

テストの実行

# Run unit tests for Slider
npm run test -- slider

# Run E2E tests for Slider (all frameworks)
npm run test:e2e:pattern --pattern=slider

# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=slider
npm run test:e2e:vue:pattern --pattern=slider
npm run test:e2e:svelte:pattern --pattern=slider
npm run test:e2e:astro:pattern --pattern=slider

テストツール

Slider.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Slider } from './Slider';

describe('Slider', () => {
  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="slider"', () => {
      render(<Slider aria-label="Volume" />);
      expect(screen.getByRole('slider')).toBeInTheDocument();
    });

    it('has aria-valuenow set to current value', () => {
      render(<Slider defaultValue={50} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '50');
    });

    it('has aria-valuenow set to min when no defaultValue', () => {
      render(<Slider min={10} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '10');
    });

    it('has aria-valuemin set (default: 0)', () => {
      render(<Slider aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuemin', '0');
    });

    it('has aria-valuemax set (default: 100)', () => {
      render(<Slider aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuemax', '100');
    });

    it('has custom aria-valuemin when provided', () => {
      render(<Slider defaultValue={50} min={10} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuemin', '10');
    });

    it('has custom aria-valuemax when provided', () => {
      render(<Slider defaultValue={50} max={200} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuemax', '200');
    });

    it('has aria-valuetext when valueText provided', () => {
      render(<Slider defaultValue={75} valueText="75 percent" aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuetext', '75 percent');
    });

    it('does not have aria-valuetext when not provided', () => {
      render(<Slider defaultValue={75} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).not.toHaveAttribute('aria-valuetext');
    });

    it('uses format for aria-valuetext', () => {
      render(<Slider defaultValue={75} format="{value}%" aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuetext', '75%');
    });

    it('updates aria-valuetext on value change', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} format="{value}%" aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuetext', '51%');
    });

    it('has aria-disabled="true" when disabled', () => {
      render(<Slider disabled aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-disabled', 'true');
    });

    it('does not have aria-disabled when not disabled', () => {
      render(<Slider aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).not.toHaveAttribute('aria-disabled');
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      render(<Slider aria-label="Volume" />);
      expect(screen.getByRole('slider', { name: 'Volume' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(
        <>
          <span id="slider-label">Brightness</span>
          <Slider aria-labelledby="slider-label" />
        </>
      );
      expect(screen.getByRole('slider', { name: 'Brightness' })).toBeInTheDocument();
    });

    it('has accessible name via visible label', () => {
      render(<Slider label="Zoom Level" />);
      expect(screen.getByRole('slider', { name: 'Zoom Level' })).toBeInTheDocument();
    });
  });

  // 🔴 High Priority: Keyboard Interaction
  describe('Keyboard Interaction', () => {
    it('increases value by step on ArrowRight', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuenow', '51');
    });

    it('decreases value by step on ArrowLeft', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowLeft}');

      expect(slider).toHaveAttribute('aria-valuenow', '49');
    });

    it('increases value by step on ArrowUp', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowUp}');

      expect(slider).toHaveAttribute('aria-valuenow', '51');
    });

    it('decreases value by step on ArrowDown', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowDown}');

      expect(slider).toHaveAttribute('aria-valuenow', '49');
    });

    it('sets min value on Home', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} min={0} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{Home}');

      expect(slider).toHaveAttribute('aria-valuenow', '0');
    });

    it('sets max value on End', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} max={100} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{End}');

      expect(slider).toHaveAttribute('aria-valuenow', '100');
    });

    it('increases value by large step on PageUp', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{PageUp}');

      expect(slider).toHaveAttribute('aria-valuenow', '60'); // default largeStep = step * 10
    });

    it('decreases value by large step on PageDown', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{PageDown}');

      expect(slider).toHaveAttribute('aria-valuenow', '40');
    });

    it('uses custom largeStep when provided', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} largeStep={20} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{PageUp}');

      expect(slider).toHaveAttribute('aria-valuenow', '70');
    });

    it('respects custom step value', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={5} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuenow', '55');
    });

    it('does not exceed max on ArrowRight', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={100} max={100} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuenow', '100');
    });

    it('does not go below min on ArrowLeft', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={0} min={0} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowLeft}');

      expect(slider).toHaveAttribute('aria-valuenow', '0');
    });

    it('does not change value when disabled', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} disabled aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      slider.focus();
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuenow', '50');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" on thumb', () => {
      render(<Slider aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('tabindex', '0');
    });

    it('has tabindex="-1" when disabled', () => {
      render(<Slider disabled aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('tabindex', '-1');
    });

    it('is focusable via Tab', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <Slider aria-label="Volume" />
          <button>After</button>
        </>
      );

      await user.tab(); // Focus "Before" button
      await user.tab(); // Focus slider

      expect(screen.getByRole('slider')).toHaveFocus();
    });

    it('is not focusable via Tab when disabled', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <Slider disabled aria-label="Volume" />
          <button>After</button>
        </>
      );

      await user.tab(); // Focus "Before" button
      await user.tab(); // Focus "After" button (skip slider)

      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
    });
  });

  // 🔴 High Priority: Orientation
  describe('Orientation', () => {
    it('does not have aria-orientation for horizontal slider', () => {
      render(<Slider orientation="horizontal" aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).not.toHaveAttribute('aria-orientation');
    });

    it('has aria-orientation="vertical" for vertical slider', () => {
      render(<Slider orientation="vertical" aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-orientation', 'vertical');
    });

    it('keyboard works correctly for vertical slider', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} orientation="vertical" aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowUp}');

      expect(slider).toHaveAttribute('aria-valuenow', '51');
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(<Slider aria-label="Volume" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with visible label', async () => {
      const { container } = render(<Slider label="Volume" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-labelledby', async () => {
      const { container } = render(
        <>
          <span id="label">Volume</span>
          <Slider aria-labelledby="label" />
        </>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(<Slider disabled aria-label="Volume" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with valueText', async () => {
      const { container } = render(
        <Slider defaultValue={50} valueText="50%" aria-label="Volume" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations at boundary values', async () => {
      const { container } = render(<Slider defaultValue={0} aria-label="Volume" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations for vertical slider', async () => {
      const { container } = render(<Slider orientation="vertical" aria-label="Volume" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Callbacks
  describe('Callbacks', () => {
    it('calls onValueChange on keyboard interaction', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(<Slider defaultValue={50} onValueChange={handleChange} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(handleChange).toHaveBeenCalledWith(51);
    });

    it('calls onValueChange with correct value on Home', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(<Slider defaultValue={50} min={0} onValueChange={handleChange} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{Home}');

      expect(handleChange).toHaveBeenCalledWith(0);
    });

    it('does not call onValueChange when disabled', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Slider defaultValue={50} disabled onValueChange={handleChange} aria-label="Volume" />
      );
      const slider = screen.getByRole('slider');

      slider.focus();
      await user.keyboard('{ArrowRight}');

      expect(handleChange).not.toHaveBeenCalled();
    });

    it('does not call onValueChange when value does not change', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Slider defaultValue={100} max={100} onValueChange={handleChange} aria-label="Volume" />
      );
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(handleChange).not.toHaveBeenCalled();
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('handles decimal step values correctly', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={0.5} min={0} max={1} step={0.1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuenow', '0.6');
    });

    it('handles negative min/max range', () => {
      render(<Slider defaultValue={0} min={-50} max={50} aria-label="Temperature" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '0');
      expect(slider).toHaveAttribute('aria-valuemin', '-50');
      expect(slider).toHaveAttribute('aria-valuemax', '50');
    });

    it('clamps defaultValue to min', () => {
      render(<Slider defaultValue={-10} min={0} max={100} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '0');
    });

    it('clamps defaultValue to max', () => {
      render(<Slider defaultValue={150} min={0} max={100} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '100');
    });

    it('rounds value to step', () => {
      render(<Slider defaultValue={53} min={0} max={100} step={5} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '55');
    });
  });

  // 🟡 Medium Priority: Visual Display
  describe('Visual Display', () => {
    it('shows value when showValue is true (default)', () => {
      render(<Slider defaultValue={75} aria-label="Volume" />);
      expect(screen.getByText('75')).toBeInTheDocument();
    });

    it('hides value when showValue is false', () => {
      render(<Slider defaultValue={75} aria-label="Volume" showValue={false} />);
      expect(screen.queryByText('75')).not.toBeInTheDocument();
    });

    it('displays formatted value when format provided', () => {
      render(<Slider defaultValue={75} format="{value}%" aria-label="Volume" />);
      expect(screen.getByText('75%')).toBeInTheDocument();
    });

    it('displays visible label when label provided', () => {
      render(<Slider label="Volume" />);
      expect(screen.getByText('Volume')).toBeInTheDocument();
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies className to container', () => {
      render(<Slider aria-label="Volume" className="custom-slider" />);
      const container = screen.getByRole('slider').closest('.apg-slider');
      expect(container).toHaveClass('custom-slider');
    });

    it('sets id attribute on slider element', () => {
      render(<Slider aria-label="Volume" id="my-slider" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('id', 'my-slider');
    });

    it('passes through data-* attributes', () => {
      render(<Slider aria-label="Volume" data-testid="custom-slider" />);
      expect(screen.getByTestId('custom-slider')).toBeInTheDocument();
    });

    it('supports aria-describedby', () => {
      render(
        <>
          <Slider aria-label="Volume" aria-describedby="desc" />
          <p id="desc">Adjust the volume level</p>
        </>
      );
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-describedby', 'desc');
    });
  });
});

リソース