Slider
An interactive control that allows users to select a value from within a range.
🤖 AI Implementation GuideDemo
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
labelprop 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
<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> Usage
<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
| 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 |
format | string | - | Format pattern for display and aria-valuetext (e.g., "{value}%", "{value} of {max}") |
Events
| Event | Payload | Description |
|---|---|---|
valueChange | number | Emitted when value changes |
One of label, aria-label, or aria-labelledby is required
for accessibility.
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
- React: React Testing Library (opens in new tab)
- Vue: Vue Testing Library (opens in new tab)
- Svelte: Svelte Testing Library (opens in new tab)
- Astro: Vitest with JSDOM for Web Component unit tests
- Accessibility: axe-core (opens in new tab)
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');
});
});
}); Resources
- WAI-ARIA APG: Slider Pattern (opens in new tab)
- MDN: <input type="range"> element (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist