APG Patterns
English GitHub
English GitHub

Slider

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

🤖 AI 実装ガイド

デモ

Volume
Progress
Rating
Vertical
Disabled

Native HTML

Use Native HTML First

Before using this custom component, consider using native <input type="range"> elements. They provide built-in keyboard support, work without JavaScript, and have native accessibility support.

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

Use custom implementations only when you need custom styling that native elements cannot provide, or when you require specific visual feedback during interactions.

Use Case Native HTML Custom Implementation
Basic value selection Recommended Not needed
Keyboard support Built-in Manual implementation
JavaScript disabled support Works natively Requires fallback
Form integration Built-in Manual implementation
Custom styling Limited (pseudo-elements) Full control
Consistent cross-browser appearance Varies significantly Consistent
Vertical orientation Limited browser support Full control

Note: Native <input type="range"> styling is notoriously inconsistent across browsers. Styling requires vendor-specific pseudo-elements (::-webkit-slider-thumb, ::-moz-range-thumb, etc.) which can be complex to maintain.

アクセシビリティ

WAI-ARIA ロール

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

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

WAI-ARIA ステート/プロパティ

aria-valuenow (必須)

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

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

aria-valuemin (必須)

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

数値
必須 はい
デフォルト 0

aria-valuemax (必須)

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

数値
必須 はい
デフォルト 100

aria-valuetext

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

文字列
必須 いいえ(値に文脈が必要な場合は推奨)
"50%", "Medium", "3 of 5 stars"

aria-orientation

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

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

aria-disabled

スライダーが無効化されており、インタラクティブではないことを示します。

真偽値
必須 いいえ

キーボードサポート

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

アクセシブルな名前付け

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

  • 可視ラベル - label プロパティを使用して可視ラベルを表示します
  • aria-label - スライダーに不可視のラベルを提供します
  • aria-labelledby - 外部要素をラベルとして参照します

ポインター操作

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

  • トラックをクリック - クリックした位置にすぐつまみを移動させます
  • つまみをドラッグ - ドラッグ中に連続的な調整が可能です
  • ポインターキャプチャ - ポインターがスライダーの外に移動しても操作を維持します

視覚デザイン

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

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

参考資料

ソースコード

Slider.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>
      )}
      <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 最小値が設定されている(デフォルト: 0)
aria-valuemax 最大値が設定されている(デフォルト: 100)
aria-valuetext 提供された場合に人間が読めるテキストが設定されている
aria-disabled 設定された場合に無効化状態が反映されている

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

テスト 説明
aria-label aria-label属性によるアクセシブルな名前
aria-labelledby 外部要素参照によるアクセシブルな名前
visible label 可視ラベルがアクセシブルな名前を提供する

高優先度: キーボード操作

テスト 説明
Arrow Right/Up 値を1ステップ増加させる
Arrow Left/Down 値を1ステップ減少させる
Home 値を最小値に設定する
End 値を最大値に設定する
Page Up/Down 値を大きなステップで増加/減少させる
Boundary clamping 値が最小/最大の制限を超えない
Disabled state 無効化時にキーボードが効果を持たない

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

テスト 説明
tabindex="0" つまみがフォーカス可能である
tabindex="-1" 無効化時につまみがフォーカス可能でない

高優先度: 向き

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

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

テスト 説明
axe violations axe-coreによってアクセシビリティ違反が検出されない

中優先度: エッジケース

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

中優先度: コールバック

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

低優先度: HTML属性の継承

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

テストツール

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

リソース