APG Patterns
日本語 GitHub
日本語 GitHub

Slider

An interactive control that allows users to select a value from within a range.

🤖 AI Implementation Guide

Demo

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.

Accessibility Features

WAI-ARIA Role

Role Element Description
slider Thumb element Identifies the element as a slider that allows users to select a value from within a range.

The slider role is used for interactive controls that let users select a value by moving a thumb along a track. Unlike the meter role, sliders are interactive and receive keyboard focus.

WAI-ARIA States and Properties

aria-valuenow (Required)

Indicates the current numeric value of the slider. Updated dynamically as the user changes the value.

Type Number
Required Yes
Range Must be between aria-valuemin and aria-valuemax

aria-valuemin (Required)

Specifies the minimum allowed value for the slider.

Type Number
Required Yes
Default 0

aria-valuemax (Required)

Specifies the maximum allowed value for the slider.

Type Number
Required Yes
Default 100

aria-valuetext

Provides a human-readable text alternative for the current value. Use when the numeric value alone doesn't convey sufficient meaning.

Type String
Required No (recommended when value needs context)
Example "50%", "Medium", "3 of 5 stars"

aria-orientation

Specifies the orientation of the slider. Only set to "vertical" for vertical sliders; omit for horizontal (default).

Type "horizontal" | "vertical"
Required No
Default horizontal (implicit)

aria-disabled

Indicates that the slider is disabled and not interactive.

Type Boolean
Required No

Keyboard Support

Key Action
Right Arrow Increases the value by one step
Up Arrow Increases the value by one step
Left Arrow Decreases the value by one step
Down Arrow Decreases the value by one step
Home Sets the slider to its minimum value
End Sets the slider to its maximum value
Page Up Increases the value by a large step (default: step * 10)
Page Down Decreases the value by a large step (default: step * 10)

Accessible Naming

Sliders must have an accessible name. This can be provided through:

  • Visible label - Using the label prop to display a visible label
  • aria-label - Provides an invisible label for the slider
  • aria-labelledby - References an external element as the label

Pointer Interaction

This implementation supports mouse and touch interaction:

  • Click on track - Immediately moves the thumb to the clicked position
  • Drag thumb - Allows continuous adjustment while dragging
  • Pointer capture - Maintains interaction even when pointer moves outside the slider

Visual Design

This implementation follows WCAG guidelines for accessible visual design:

  • Focus indicator - Visible focus ring on the thumb element
  • Visual fill - Proportionally represents the current value
  • Hover states - Visual feedback on hover
  • Disabled state - Clear visual indication when slider is disabled
  • Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode

References

Source Code

Slider.astro
---
/**
 * APG Slider Pattern - Astro Implementation
 *
 * A control that allows users to select a value from within a range.
 * Uses Web Components for interactive behavior.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/slider/
 */

export interface Props {
  /** Default value */
  defaultValue?: number;
  /** Minimum value (default: 0) */
  min?: number;
  /** Maximum value (default: 100) */
  max?: number;
  /** Step increment (default: 1) */
  step?: number;
  /** Large step for PageUp/PageDown */
  largeStep?: number;
  /** Slider orientation */
  orientation?: 'horizontal' | 'vertical';
  /** Whether slider is disabled */
  disabled?: boolean;
  /** Show value text (default: true) */
  showValue?: boolean;
  /** Visible label text */
  label?: string;
  /** Human-readable value text for aria-valuetext */
  valueText?: string;
  /** Format pattern for dynamic value display (e.g., "{value}%", "{value} of {max}") */
  format?: string;
  /** Slider id */
  id?: string;
  /** Additional CSS class */
  class?: string;
  /** Accessible label when no visible label */
  'aria-label'?: string;
  /** Reference to external label element */
  'aria-labelledby'?: string;
  /** Reference to description element */
  'aria-describedby'?: string;
}

const {
  defaultValue,
  min = 0,
  max = 100,
  step = 1,
  largeStep,
  orientation = 'horizontal',
  disabled = false,
  showValue = true,
  label,
  valueText,
  format,
  id,
  class: className = '',
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  'aria-describedby': ariaDescribedby,
} = Astro.props;

// Utility functions
const clamp = (val: number, minVal: number, maxVal: number): number => {
  return Math.min(maxVal, Math.max(minVal, val));
};

const roundToStep = (val: number, stepVal: number, minVal: number): number => {
  const steps = Math.round((val - minVal) / stepVal);
  const result = minVal + steps * stepVal;
  const decimalPlaces = (stepVal.toString().split('.')[1] || '').length;
  return Number(result.toFixed(decimalPlaces));
};

// Calculate initial value
const initialValue = clamp(roundToStep(defaultValue ?? min, step, min), min, max);

// Calculate percentage for visual display
const percentage = max === min ? 0 : ((initialValue - min) / (max - min)) * 100;

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

// Display text
const displayText = valueText ?? formatValueText(initialValue, format);

// Initial aria-valuetext
const initialAriaValueText =
  valueText ?? (format ? formatValueText(initialValue, format) : undefined);

// Generate unique label ID
const labelId = label ? `slider-label-${Math.random().toString(36).slice(2, 9)}` : undefined;

const isVertical = orientation === 'vertical';
const effectiveLargeStep = largeStep ?? step * 10;
---

<apg-slider
  data-min={min}
  data-max={max}
  data-step={step}
  data-large-step={effectiveLargeStep}
  data-orientation={orientation}
  data-disabled={disabled}
  data-format={format}
>
  <div
    class={`apg-slider ${isVertical ? 'apg-slider--vertical' : ''} ${disabled ? 'apg-slider--disabled' : ''} ${className}`.trim()}
  >
    {
      label && (
        <span id={labelId} class="apg-slider-label">
          {label}
        </span>
      )
    }
    <div class="apg-slider-track" style={`--slider-position: ${percentage}%`}>
      <div class="apg-slider-fill" aria-hidden="true"></div>
      <div
        role="slider"
        id={id}
        tabindex={disabled ? -1 : 0}
        aria-valuenow={initialValue}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuetext={initialAriaValueText}
        aria-label={label ? undefined : ariaLabel}
        aria-labelledby={ariaLabelledby ?? labelId}
        aria-orientation={isVertical ? 'vertical' : undefined}
        aria-disabled={disabled ? true : undefined}
        aria-describedby={ariaDescribedby}
        class="apg-slider-thumb"
      >
      </div>
    </div>
    {
      showValue && (
        <span class="apg-slider-value" aria-hidden="true">
          {displayText}
        </span>
      )
    }
  </div>
</apg-slider>

<script>
  class ApgSlider extends HTMLElement {
    private thumb: HTMLElement | null = null;
    private track: HTMLElement | null = null;
    private valueDisplay: HTMLElement | null = null;
    private isDragging = false;

    connectedCallback() {
      this.thumb = this.querySelector('[role="slider"]');
      this.track = this.querySelector('.apg-slider-track');
      this.valueDisplay = this.querySelector('.apg-slider-value');

      if (this.thumb) {
        this.thumb.addEventListener('keydown', this.handleKeyDown.bind(this));
        this.thumb.addEventListener('pointerdown', this.handlePointerDown.bind(this));
        this.thumb.addEventListener('pointermove', this.handlePointerMove.bind(this));
        this.thumb.addEventListener('pointerup', this.handlePointerUp.bind(this));
      }

      if (this.track) {
        this.track.addEventListener('click', this.handleTrackClick.bind(this));
      }
    }

    disconnectedCallback() {
      if (this.thumb) {
        this.thumb.removeEventListener('keydown', this.handleKeyDown.bind(this));
        this.thumb.removeEventListener('pointerdown', this.handlePointerDown.bind(this));
        this.thumb.removeEventListener('pointermove', this.handlePointerMove.bind(this));
        this.thumb.removeEventListener('pointerup', this.handlePointerUp.bind(this));
      }

      if (this.track) {
        this.track.removeEventListener('click', this.handleTrackClick.bind(this));
      }
    }

    private get min(): number {
      return Number(this.dataset.min) || 0;
    }

    private get max(): number {
      return Number(this.dataset.max) || 100;
    }

    private get step(): number {
      return Number(this.dataset.step) || 1;
    }

    private get largeStep(): number {
      return Number(this.dataset.largeStep) || this.step * 10;
    }

    private get isVertical(): boolean {
      return this.dataset.orientation === 'vertical';
    }

    private get isDisabled(): boolean {
      return this.dataset.disabled === 'true';
    }

    private get format(): string | undefined {
      return this.dataset.format;
    }

    private formatValue(value: number): string {
      const fmt = this.format;
      if (!fmt) return String(value);
      return fmt
        .replace('{value}', String(value))
        .replace('{min}', String(this.min))
        .replace('{max}', String(this.max));
    }

    private get currentValue(): number {
      return Number(this.thumb?.getAttribute('aria-valuenow')) || this.min;
    }

    private clamp(val: number): number {
      return Math.min(this.max, Math.max(this.min, val));
    }

    private roundToStep(val: number): number {
      const steps = Math.round((val - this.min) / this.step);
      const result = this.min + steps * this.step;
      const decimalPlaces = (this.step.toString().split('.')[1] || '').length;
      return Number(result.toFixed(decimalPlaces));
    }

    private updateValue(newValue: number) {
      if (!this.thumb || this.isDisabled) return;

      const clampedValue = this.clamp(this.roundToStep(newValue));
      const currentValue = this.currentValue;

      if (clampedValue === currentValue) return;

      // Update ARIA
      this.thumb.setAttribute('aria-valuenow', String(clampedValue));

      // Update aria-valuetext if format is provided
      const formattedValue = this.formatValue(clampedValue);
      if (this.format) {
        this.thumb.setAttribute('aria-valuetext', formattedValue);
      }

      // Update visual via CSS custom property
      const percentage = ((clampedValue - this.min) / (this.max - this.min)) * 100;

      if (this.track) {
        this.track.style.setProperty('--slider-position', `${percentage}%`);
      }

      if (this.valueDisplay) {
        this.valueDisplay.textContent = formattedValue;
      }

      // Dispatch event
      this.dispatchEvent(
        new CustomEvent('valuechange', {
          detail: { value: clampedValue },
          bubbles: true,
        })
      );
    }

    private getValueFromPointer(clientX: number, clientY: number): number {
      if (!this.track) return this.currentValue;

      const rect = this.track.getBoundingClientRect();

      if (rect.width === 0 && rect.height === 0) {
        return this.currentValue;
      }

      let percent: number;

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

      return this.min + percent * (this.max - this.min);
    }

    private handleKeyDown(event: KeyboardEvent) {
      if (this.isDisabled) return;

      let newValue = this.currentValue;

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

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

    private handlePointerDown(event: PointerEvent) {
      if (this.isDisabled || !this.thumb) return;

      event.preventDefault();

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

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

    private handlePointerMove(event: PointerEvent) {
      if (!this.thumb) return;

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

      if (!hasCapture) return;

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

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

    private handleTrackClick(event: MouseEvent) {
      if (this.isDisabled || event.target === this.thumb) return;

      const newValue = this.getValueFromPointer(event.clientX, event.clientY);
      this.updateValue(newValue);
      this.thumb?.focus();
    }

    // Public method to update value programmatically
    setValue(newValue: number) {
      this.updateValue(newValue);
    }
  }

  if (!customElements.get('apg-slider')) {
    customElements.define('apg-slider', ApgSlider);
  }
</script>

Usage

Example
---
import Slider from './Slider.astro';
---

<!-- Basic usage with aria-label -->
<Slider defaultValue={50} aria-label="Volume" />

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

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

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

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

<!-- Dynamic updates via Web Component API -->
<Slider defaultValue={50} id="my-slider" label="Volume" />
<script>
  const slider = document.querySelector('#my-slider').closest('apg-slider');
  slider.setValue(75);

  slider.addEventListener('valuechange', (e) => {
    console.log('Value changed:', e.detail.value);
  });
</script>

API

Prop Type Default Description
defaultValue number min Initial value of the slider
min number 0 Minimum value
max number 100 Maximum value
step number 1 Step increment for keyboard navigation
largeStep number step * 10 Large step for PageUp/PageDown
orientation 'horizontal' | 'vertical' 'horizontal' Slider orientation
disabled boolean false Whether the slider is disabled
showValue boolean true Whether to display the value text
label string - Visible label (also used as aria-labelledby)
valueText string - Human-readable value for aria-valuetext (static)
format string - Format pattern for display and aria-valuetext (e.g., "{value}%", "{value} of {max}")

One of label, aria-label, or aria-labelledby is required for accessibility.

Web Component Methods

Method Description
setValue(value: number) Programmatically update the slider value

Custom Events

Event Detail
valuechange { value: number } - Fired when value changes via keyboard, pointer, or setValue()

Testing

Tests verify APG compliance for ARIA attributes, keyboard interactions, pointer operations, and accessibility requirements.

Test Categories

High Priority: ARIA Attributes

Test Description
role="slider" Element has the slider role
aria-valuenow Current value is correctly set and updated
aria-valuemin Minimum value is set (default: 0)
aria-valuemax Maximum value is set (default: 100)
aria-valuetext Human-readable text is set when provided
aria-disabled Disabled state is reflected when set

High Priority: Accessible Name

Test Description
aria-label Accessible name via aria-label attribute
aria-labelledby Accessible name via external element reference
visible label Visible label provides accessible name

High Priority: Keyboard Interaction

Test Description
Arrow Right/Up Increases value by one step
Arrow Left/Down Decreases value by one step
Home Sets value to minimum
End Sets value to maximum
Page Up/Down Increases/decreases value by large step
Boundary clamping Value does not exceed min/max limits
Disabled state Keyboard has no effect when disabled

High Priority: Focus Management

Test Description
tabindex="0" Thumb is focusable
tabindex="-1" Thumb is not focusable when disabled

High Priority: Orientation

Test Description
horizontal No aria-orientation for horizontal slider
vertical aria-orientation="vertical" is set

Medium Priority: Accessibility

Test Description
axe violations No accessibility violations detected by axe-core

Medium Priority: Edge Cases

Test Description
decimal values Handles decimal step values correctly
negative range Handles negative min/max ranges
clamp to min defaultValue below min is clamped to min
clamp to max defaultValue above max is clamped to max

Medium Priority: Callbacks

Test Description
onValueChange Callback is called with new value on change

Low Priority: HTML Attribute Inheritance

Test Description
className Custom class is applied to container
id ID attribute is set correctly
data-* Data attributes are passed through

Testing Tools

Slider.test.astro.ts
/**
 * Slider Web Component Tests
 *
 * Unit tests for the Web Component class.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

describe('Slider (Web Component)', () => {
  let container: HTMLElement;

  // Web Component class extracted for testing
  class TestApgSlider extends HTMLElement {
    private thumb: HTMLElement | null = null;
    private track: HTMLElement | null = null;
    private fill: HTMLElement | null = null;
    private valueDisplay: HTMLElement | null = null;
    private isDragging = false;

    connectedCallback() {
      this.thumb = this.querySelector('[role="slider"]');
      this.track = this.querySelector('.apg-slider-track');
      this.fill = this.querySelector('.apg-slider-fill');
      this.valueDisplay = this.querySelector('.apg-slider-value');

      if (this.thumb) {
        this.thumb.addEventListener('keydown', this.handleKeyDown.bind(this));
      }
    }

    private get min(): number {
      return Number(this.dataset.min) || 0;
    }

    private get max(): number {
      return Number(this.dataset.max) || 100;
    }

    private get step(): number {
      return Number(this.dataset.step) || 1;
    }

    private get largeStep(): number {
      return Number(this.dataset.largeStep) || this.step * 10;
    }

    private get isVertical(): boolean {
      return this.dataset.orientation === 'vertical';
    }

    private get isDisabled(): boolean {
      return this.dataset.disabled === 'true';
    }

    private get currentValue(): number {
      return Number(this.thumb?.getAttribute('aria-valuenow')) || this.min;
    }

    private clamp(val: number): number {
      return Math.min(this.max, Math.max(this.min, val));
    }

    private roundToStep(val: number): number {
      const steps = Math.round((val - this.min) / this.step);
      const result = this.min + steps * this.step;
      const decimalPlaces = (this.step.toString().split('.')[1] || '').length;
      return Number(result.toFixed(decimalPlaces));
    }

    private updateValue(newValue: number) {
      if (!this.thumb || this.isDisabled) return;

      const clampedValue = this.clamp(this.roundToStep(newValue));
      const currentValue = this.currentValue;

      if (clampedValue === currentValue) return;

      this.thumb.setAttribute('aria-valuenow', String(clampedValue));

      const percentage = ((clampedValue - this.min) / (this.max - this.min)) * 100;

      if (this.fill) {
        this.fill.style[this.isVertical ? 'height' : 'width'] = `${percentage}%`;
      }

      this.thumb.style[this.isVertical ? 'bottom' : 'left'] = `${percentage}%`;

      if (this.valueDisplay) {
        this.valueDisplay.textContent = String(clampedValue);
      }

      this.dispatchEvent(
        new CustomEvent('valuechange', {
          detail: { value: clampedValue },
          bubbles: true,
        })
      );
    }

    private handleKeyDown(event: KeyboardEvent) {
      if (this.isDisabled) return;

      let newValue = this.currentValue;

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

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

    setValue(newValue: number) {
      this.updateValue(newValue);
    }

    // Expose for testing
    get _thumb() {
      return this.thumb;
    }
    get _fill() {
      return this.fill;
    }
    get _valueDisplay() {
      return this.valueDisplay;
    }
  }

  function createSliderHTML(
    options: {
      defaultValue?: number;
      min?: number;
      max?: number;
      step?: number;
      largeStep?: number;
      orientation?: 'horizontal' | 'vertical';
      disabled?: boolean;
      showValue?: boolean;
      label?: string;
      valueText?: string;
      id?: string;
      ariaLabel?: string;
      ariaLabelledby?: string;
    } = {}
  ) {
    const {
      defaultValue,
      min = 0,
      max = 100,
      step = 1,
      largeStep,
      orientation = 'horizontal',
      disabled = false,
      showValue = true,
      label,
      valueText,
      id,
      ariaLabel = 'Volume',
      ariaLabelledby,
    } = options;

    // Calculate initial value
    const clamp = (val: number, minVal: number, maxVal: number) =>
      Math.min(maxVal, Math.max(minVal, val));
    const roundToStep = (val: number, stepVal: number, minVal: number) => {
      const steps = Math.round((val - minVal) / stepVal);
      return minVal + steps * stepVal;
    };

    const initialValue = clamp(roundToStep(defaultValue ?? min, step, min), min, max);
    const percentage = max === min ? 0 : ((initialValue - min) / (max - min)) * 100;
    const isVertical = orientation === 'vertical';
    const effectiveLargeStep = largeStep ?? step * 10;
    const labelId = label ? `slider-label-${Math.random().toString(36).slice(2, 9)}` : undefined;

    return `
      <apg-slider
        data-min="${min}"
        data-max="${max}"
        data-step="${step}"
        data-large-step="${effectiveLargeStep}"
        data-orientation="${orientation}"
        data-disabled="${disabled}"
      >
        <div class="apg-slider ${isVertical ? 'apg-slider--vertical' : ''} ${disabled ? 'apg-slider--disabled' : ''}">
          ${label ? `<span id="${labelId}" class="apg-slider-label">${label}</span>` : ''}
          <div class="apg-slider-track">
            <div
              class="apg-slider-fill"
              style="${isVertical ? `height: ${percentage}%` : `width: ${percentage}%`}"
              aria-hidden="true"
            ></div>
            <div
              role="slider"
              ${id ? `id="${id}"` : ''}
              tabindex="${disabled ? -1 : 0}"
              aria-valuenow="${initialValue}"
              aria-valuemin="${min}"
              aria-valuemax="${max}"
              ${valueText ? `aria-valuetext="${valueText}"` : ''}
              ${ariaLabelledby ? `aria-labelledby="${ariaLabelledby}"` : label ? `aria-labelledby="${labelId}"` : `aria-label="${ariaLabel}"`}
              ${isVertical ? `aria-orientation="vertical"` : ''}
              ${disabled ? `aria-disabled="true"` : ''}
              class="apg-slider-thumb"
              style="${isVertical ? `bottom: ${percentage}%` : `left: ${percentage}%`}"
            ></div>
          </div>
          ${showValue ? `<span class="apg-slider-value" aria-hidden="true">${initialValue}</span>` : ''}
        </div>
      </apg-slider>
    `;
  }

  beforeEach(() => {
    // Register custom element if not already registered
    if (!customElements.get('apg-slider')) {
      customElements.define('apg-slider', TestApgSlider);
    }

    container = document.createElement('div');
    document.body.appendChild(container);
  });

  afterEach(() => {
    document.body.removeChild(container);
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="slider"', () => {
      container.innerHTML = createSliderHTML();
      expect(container.querySelector('[role="slider"]')).not.toBeNull();
    });

    it('has aria-valuenow set to current value', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 50 });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuenow')).toBe('50');
    });

    it('has aria-valuenow set to min when no defaultValue', () => {
      container.innerHTML = createSliderHTML({ min: 10 });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuenow')).toBe('10');
    });

    it('has aria-valuemin set', () => {
      container.innerHTML = createSliderHTML({ min: 0 });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuemin')).toBe('0');
    });

    it('has aria-valuemax set', () => {
      container.innerHTML = createSliderHTML({ max: 100 });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuemax')).toBe('100');
    });

    it('has aria-valuetext when valueText provided', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 75, valueText: '75 percent' });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuetext')).toBe('75 percent');
    });

    it('does not have aria-valuetext when not provided', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 75 });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.hasAttribute('aria-valuetext')).toBe(false);
    });

    it('has aria-disabled="true" when disabled', () => {
      container.innerHTML = createSliderHTML({ disabled: true });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-disabled')).toBe('true');
    });

    it('has aria-orientation="vertical" for vertical slider', () => {
      container.innerHTML = createSliderHTML({ orientation: 'vertical' });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-orientation')).toBe('vertical');
    });

    it('does not have aria-orientation for horizontal slider', () => {
      container.innerHTML = createSliderHTML({ orientation: 'horizontal' });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.hasAttribute('aria-orientation')).toBe(false);
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      container.innerHTML = createSliderHTML({ ariaLabel: 'Volume' });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-label')).toBe('Volume');
    });

    it('has accessible name via aria-labelledby', () => {
      container.innerHTML = createSliderHTML({ ariaLabelledby: 'external-label' });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-labelledby')).toBe('external-label');
    });

    it('has accessible name via visible label', () => {
      container.innerHTML = createSliderHTML({ label: 'Brightness' });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.hasAttribute('aria-labelledby')).toBe(true);
      expect(container.querySelector('.apg-slider-label')?.textContent).toBe('Brightness');
    });
  });

  // 🔴 High Priority: Keyboard Interaction
  describe('Keyboard Interaction', () => {
    it('increases value by step on ArrowRight', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 50, step: 1 });
      const sliderComponent = container.querySelector('apg-slider') as TestApgSlider;
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
      slider.dispatchEvent(event);

      expect(slider.getAttribute('aria-valuenow')).toBe('51');
    });

    it('decreases value by step on ArrowLeft', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 50, step: 1 });
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true });
      slider.dispatchEvent(event);

      expect(slider.getAttribute('aria-valuenow')).toBe('49');
    });

    it('sets min value on Home', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 50, min: 0 });
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'Home', bubbles: true });
      slider.dispatchEvent(event);

      expect(slider.getAttribute('aria-valuenow')).toBe('0');
    });

    it('sets max value on End', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 50, max: 100 });
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'End', bubbles: true });
      slider.dispatchEvent(event);

      expect(slider.getAttribute('aria-valuenow')).toBe('100');
    });

    it('increases value by large step on PageUp', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 50, step: 1 });
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true });
      slider.dispatchEvent(event);

      expect(slider.getAttribute('aria-valuenow')).toBe('60');
    });

    it('decreases value by large step on PageDown', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 50, step: 1 });
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true });
      slider.dispatchEvent(event);

      expect(slider.getAttribute('aria-valuenow')).toBe('40');
    });

    it('does not exceed max on ArrowRight', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 100, max: 100 });
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
      slider.dispatchEvent(event);

      expect(slider.getAttribute('aria-valuenow')).toBe('100');
    });

    it('does not go below min on ArrowLeft', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 0, min: 0 });
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true });
      slider.dispatchEvent(event);

      expect(slider.getAttribute('aria-valuenow')).toBe('0');
    });

    it('does not change value when disabled', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 50, disabled: true });
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
      slider.dispatchEvent(event);

      expect(slider.getAttribute('aria-valuenow')).toBe('50');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" on thumb', () => {
      container.innerHTML = createSliderHTML();
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('tabindex')).toBe('0');
    });

    it('has tabindex="-1" when disabled', () => {
      container.innerHTML = createSliderHTML({ disabled: true });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('tabindex')).toBe('-1');
    });
  });

  // 🟡 Medium Priority: Events
  describe('Events', () => {
    it('dispatches valuechange event on value change', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 50 });
      const sliderComponent = container.querySelector('apg-slider') as TestApgSlider;
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const eventHandler = vi.fn();
      sliderComponent.addEventListener('valuechange', eventHandler);

      const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
      slider.dispatchEvent(event);

      expect(eventHandler).toHaveBeenCalled();
      expect(eventHandler.mock.calls[0][0].detail.value).toBe(51);
    });

    it('does not dispatch event when value does not change', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 100, max: 100 });
      const sliderComponent = container.querySelector('apg-slider') as TestApgSlider;
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const eventHandler = vi.fn();
      sliderComponent.addEventListener('valuechange', eventHandler);

      const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
      slider.dispatchEvent(event);

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

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('handles decimal step values correctly', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 0.5, min: 0, max: 1, step: 0.1 });
      const slider = container.querySelector('[role="slider"]') as HTMLElement;

      const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true });
      slider.dispatchEvent(event);

      expect(slider.getAttribute('aria-valuenow')).toBe('0.6');
    });

    it('clamps value to min/max', () => {
      container.innerHTML = createSliderHTML({ defaultValue: -10, min: 0, max: 100 });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuenow')).toBe('0');
    });

    it('rounds value to step', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 53, min: 0, max: 100, step: 5 });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuenow')).toBe('55');
    });
  });

  // 🟡 Medium Priority: Visual Display
  describe('Visual Display', () => {
    it('shows value when showValue is true', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 75, showValue: true });
      expect(container.querySelector('.apg-slider-value')?.textContent).toBe('75');
    });

    it('hides value when showValue is false', () => {
      container.innerHTML = createSliderHTML({ defaultValue: 75, showValue: false });
      expect(container.querySelector('.apg-slider-value')).toBeNull();
    });

    it('displays visible label when label provided', () => {
      container.innerHTML = createSliderHTML({ label: 'Volume' });
      expect(container.querySelector('.apg-slider-label')?.textContent).toBe('Volume');
    });
  });

  // 🟢 Low Priority: HTML Attributes
  describe('HTML Attributes', () => {
    it('sets id attribute', () => {
      container.innerHTML = createSliderHTML({ id: 'my-slider' });
      const slider = container.querySelector('[role="slider"]');
      expect(slider?.getAttribute('id')).toBe('my-slider');
    });
  });
});

Resources