APG Patterns
English GitHub
English GitHub

Slider

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

🤖 AI Implementation Guide

デモ

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.vue
<template>
  <div
    :class="[
      'apg-slider',
      isVertical && 'apg-slider--vertical',
      disabled && 'apg-slider--disabled',
      $attrs.class,
    ]"
  >
    <span v-if="label" :id="labelId" class="apg-slider-label">
      {{ label }}
    </span>
    <div
      ref="trackRef"
      class="apg-slider-track"
      :style="{ '--slider-position': `${percent}%` }"
      @click="handleTrackClick"
    >
      <div class="apg-slider-fill" aria-hidden="true" />
      <div
        ref="thumbRef"
        role="slider"
        :id="$attrs.id"
        :tabindex="disabled ? -1 : 0"
        :aria-valuenow="value"
        :aria-valuemin="min"
        :aria-valuemax="max"
        :aria-valuetext="ariaValueText"
        :aria-label="$attrs['aria-label']"
        :aria-labelledby="ariaLabelledby"
        :aria-orientation="isVertical ? 'vertical' : undefined"
        :aria-disabled="disabled ? true : undefined"
        :aria-describedby="$attrs['aria-describedby']"
        :data-testid="$attrs['data-testid']"
        class="apg-slider-thumb"
        @keydown="handleKeyDown"
        @pointerdown="handlePointerDown"
        @pointermove="handlePointerMove"
        @pointerup="handlePointerUp"
      />
    </div>
    <span v-if="showValue" class="apg-slider-value" aria-hidden="true">
      {{ displayText }}
    </span>
  </div>
</template>

<script setup lang="ts">
import { computed, ref, useId } from 'vue';

defineOptions({
  inheritAttrs: false,
});

export interface SliderProps {
  /** 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;
}

const props = withDefaults(defineProps<SliderProps>(), {
  defaultValue: undefined,
  min: 0,
  max: 100,
  step: 1,
  largeStep: undefined,
  orientation: 'horizontal',
  disabled: false,
  showValue: true,
  label: undefined,
  valueText: undefined,
  format: undefined,
});

const emit = defineEmits<{
  valueChange: [value: number];
}>();

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

const getPercent = (val: number, minVal: number, maxVal: number): number => {
  if (maxVal === minVal) return 0;
  return ((val - minVal) / (maxVal - minVal)) * 100;
};

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

// Refs
const thumbRef = ref<HTMLDivElement | null>(null);
const trackRef = ref<HTMLDivElement | null>(null);
const labelId = useId();
const isDragging = ref(false);

// State
const initialValue = clamp(
  roundToStep(props.defaultValue ?? props.min, props.step, props.min),
  props.min,
  props.max
);
const value = ref(initialValue);

// Computed
const isVertical = computed(() => props.orientation === 'vertical');
const effectiveLargeStep = computed(() => props.largeStep ?? props.step * 10);
const percent = computed(() => getPercent(value.value, props.min, props.max));

const ariaValueText = computed(() => {
  if (props.valueText) return props.valueText;
  if (props.format) return formatValueText(value.value, props.format, props.min, props.max);
  return undefined;
});

const displayText = computed(() => {
  if (props.valueText) return props.valueText;
  return formatValueText(value.value, props.format, props.min, props.max);
});

const ariaLabelledby = computed(() => {
  const attrLabelledby = (
    getCurrentInstance()?.attrs as { 'aria-labelledby'?: string } | undefined
  )?.['aria-labelledby'];
  return attrLabelledby ?? (props.label ? labelId : undefined);
});

// Helper to get current instance for attrs
import { getCurrentInstance } from 'vue';

// Update value and emit
const updateValue = (newValue: number) => {
  const clampedValue = clamp(roundToStep(newValue, props.step, props.min), props.min, props.max);
  if (clampedValue !== value.value) {
    value.value = clampedValue;
    emit('valueChange', clampedValue);
  }
};

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

  const rect = track.getBoundingClientRect();

  // Guard against zero-size track
  if (rect.width === 0 && rect.height === 0) {
    return value.value;
  }

  let pct: number;

  if (isVertical.value) {
    if (rect.height === 0) return value.value;
    pct = 1 - (clientY - rect.top) / rect.height;
  } else {
    if (rect.width === 0) return value.value;
    pct = (clientX - rect.left) / rect.width;
  }

  const rawValue = props.min + pct * (props.max - props.min);
  return clamp(roundToStep(rawValue, props.step, props.min), props.min, props.max);
};

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

  let newValue = value.value;

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

  event.preventDefault();
  updateValue(newValue);
};

// Pointer handlers
const handlePointerDown = (event: PointerEvent) => {
  if (props.disabled) return;

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

  if (typeof thumb.setPointerCapture === 'function') {
    thumb.setPointerCapture(event.pointerId);
  }
  isDragging.value = true;
  thumb.focus();

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

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

  const hasCapture =
    typeof thumb.hasPointerCapture === 'function'
      ? thumb.hasPointerCapture(event.pointerId)
      : isDragging.value;

  if (!hasCapture) return;

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

const handlePointerUp = (event: PointerEvent) => {
  const thumb = thumbRef.value;
  if (thumb && typeof thumb.releasePointerCapture === 'function') {
    try {
      thumb.releasePointerCapture(event.pointerId);
    } catch {
      // Ignore
    }
  }
  isDragging.value = false;
};

// Track click handler
const handleTrackClick = (event: MouseEvent) => {
  if (props.disabled) return;
  if (event.target === thumbRef.value) return;

  const newValue = getValueFromPointer(event.clientX, event.clientY);
  updateValue(newValue);
  thumbRef.value?.focus();
};
</script>

使い方

Example
<script setup>
import Slider from './Slider.vue';

function handleChange(value) {
  console.log('Value changed:', value);
}
</script>

<template>
  <div>
    <!-- Basic usage with aria-label -->
    <Slider :default-value="50" aria-label="Volume" />

    <!-- With visible label -->
    <Slider :default-value="50" label="Volume" />

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

    <!-- Custom range with step -->
    <Slider
      :default-value="3"
      :min="1"
      :max="5"
      :step="1"
      label="Rating"
      format="{value} of {max}"
    />

    <!-- Vertical slider -->
    <Slider
      :default-value="50"
      label="Volume"
      orientation="vertical"
    />

    <!-- With callback -->
    <Slider
      :default-value="50"
      label="Value"
      @value-change="handleChange"
    />
  </div>
</template>

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

Events

イベント ペイロード 説明
valueChange number 値が変更されたときに発火

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.vue.ts
import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import Slider from './Slider.vue';

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

    it('has aria-valuenow set to current value', () => {
      render(Slider, {
        props: { defaultValue: 50 },
        attrs: { '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, {
        props: { min: 10 },
        attrs: { 'aria-label': 'Volume' },
      });
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '10');
    });

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

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

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

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

    it('has aria-valuetext when valueText provided', () => {
      render(Slider, {
        props: { defaultValue: 75, valueText: '75 percent' },
        attrs: { '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, {
        props: { defaultValue: 75 },
        attrs: { 'aria-label': 'Volume' },
      });
      const slider = screen.getByRole('slider');
      expect(slider).not.toHaveAttribute('aria-valuetext');
    });

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

    it('has aria-disabled="true" when disabled', () => {
      render(Slider, {
        props: { disabled: true },
        attrs: { '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, {
        attrs: { '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, {
        attrs: { 'aria-label': 'Volume' },
      });
      expect(screen.getByRole('slider', { name: 'Volume' })).toBeInTheDocument();
    });

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

    it('has accessible name via visible label', () => {
      render(Slider, {
        props: { 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, {
        props: { defaultValue: 50, step: 1 },
        attrs: { '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, {
        props: { defaultValue: 50, step: 1 },
        attrs: { 'aria-label': 'Volume' },
      });
      const slider = screen.getByRole('slider');

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

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

    it('sets min value on Home', async () => {
      const user = userEvent.setup();
      render(Slider, {
        props: { defaultValue: 50, min: 0 },
        attrs: { '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, {
        props: { defaultValue: 50, max: 100 },
        attrs: { '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, {
        props: { defaultValue: 50, step: 1 },
        attrs: { 'aria-label': 'Volume' },
      });
      const slider = screen.getByRole('slider');

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

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

    it('does not exceed max on ArrowRight', async () => {
      const user = userEvent.setup();
      render(Slider, {
        props: { defaultValue: 100, max: 100 },
        attrs: { '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 change value when disabled', async () => {
      const user = userEvent.setup();
      render(Slider, {
        props: { defaultValue: 50, disabled: true },
        attrs: { '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, {
        attrs: { 'aria-label': 'Volume' },
      });
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('tabindex', '0');
    });

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

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

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

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

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

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

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

  // 🟡 Medium Priority: Events
  describe('Events', () => {
    it('emits valueChange on keyboard interaction', async () => {
      const user = userEvent.setup();
      const { emitted } = render(Slider, {
        props: { defaultValue: 50 },
        attrs: { 'aria-label': 'Volume' },
      });
      const slider = screen.getByRole('slider');

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

      expect(emitted('valueChange')).toBeTruthy();
      expect(emitted('valueChange')[0]).toEqual([51]);
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('handles decimal step values correctly', async () => {
      const user = userEvent.setup();
      render(Slider, {
        props: { defaultValue: 0.5, min: 0, max: 1, step: 0.1 },
        attrs: { '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, {
        props: { defaultValue: 0, min: -50, max: 50 },
        attrs: { '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, {
        props: { defaultValue: -10, min: 0, max: 100 },
        attrs: { 'aria-label': 'Volume' },
      });
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '0');
    });

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

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

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

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

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

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

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

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

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

リソース