import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Slider } from './slider';
import { SLIDER_CHANGE_EVENT, type SliderChangeEvent } from './slider.types';
const SINGLE = `
`;
const RANGE = `
`;
function setup(markup: string): HTMLElement {
document.body.innerHTML = markup;
return document.body.firstElementChild as HTMLElement;
}
function thumbs(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll('[data-c42-slider-thumb]'));
}
function press(el: HTMLElement, key: string): void {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
}
describe('Slider', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('wires slider role and ARIA attributes on the thumb', () => {
const root = setup(SINGLE);
new Slider(root, { min: 0, max: 100, value: 30 });
const [thumb] = thumbs(root);
expect(thumb.getAttribute('role')).toBe('slider');
expect(thumb.getAttribute('aria-valuemin')).toBe('0');
expect(thumb.getAttribute('aria-valuemax')).toBe('100');
expect(thumb.getAttribute('aria-valuenow')).toBe('30');
expect(thumb.getAttribute('tabindex')).toBe('0');
});
it('exposes the end percentage as a custom property for single sliders', () => {
const root = setup(SINGLE);
new Slider(root, { value: 25 });
expect(root.style.getPropertyValue('--c42-slider-end-percent')).toBe('25%');
});
it('increments and decrements with arrow keys', () => {
const root = setup(SINGLE);
const slider = new Slider(root, { value: 50, step: 5 });
const [thumb] = thumbs(root);
press(thumb, 'ArrowRight');
expect(slider.getValue()).toBe(55);
press(thumb, 'ArrowLeft');
expect(slider.getValue()).toBe(50);
press(thumb, 'ArrowUp');
expect(slider.getValue()).toBe(55);
press(thumb, 'ArrowDown');
expect(slider.getValue()).toBe(50);
});
it('jumps to min/max with Home/End and pages with PageUp/PageDown', () => {
const root = setup(SINGLE);
const slider = new Slider(root, { value: 50, step: 2 });
const [thumb] = thumbs(root);
press(thumb, 'Home');
expect(slider.getValue()).toBe(0);
press(thumb, 'End');
expect(slider.getValue()).toBe(100);
press(thumb, 'PageDown');
expect(slider.getValue()).toBe(80);
press(thumb, 'PageUp');
expect(slider.getValue()).toBe(100);
});
it('clamps to [min, max]', () => {
const root = setup(SINGLE);
const slider = new Slider(root, { min: 0, max: 10, value: 10, step: 1 });
const [thumb] = thumbs(root);
press(thumb, 'ArrowRight');
expect(slider.getValue()).toBe(10);
});
it('snaps values to the step', () => {
const root = setup(SINGLE);
const slider = new Slider(root, { step: 10, value: 23 });
expect(slider.getValue()).toBe(20);
});
it('infers a range slider from two thumbs', () => {
const root = setup(RANGE);
const slider = new Slider(root, { value: [20, 80] });
expect(slider.getValue()).toEqual([20, 80]);
expect(root.dataset.range).toBe('');
expect(root.style.getPropertyValue('--c42-slider-start-percent')).toBe('20%');
expect(root.style.getPropertyValue('--c42-slider-end-percent')).toBe('80%');
});
it('prevents the start thumb from crossing the end thumb', () => {
const root = setup(RANGE);
const slider = new Slider(root, { value: [40, 50], step: 5 });
const [start] = thumbs(root);
for (let i = 0; i < 5; i += 1) {
press(start, 'ArrowRight');
}
expect((slider.getValue() as [number, number])[0]).toBe(50);
});
it('prevents the end thumb from crossing the start thumb', () => {
const root = setup(RANGE);
const slider = new Slider(root, { value: [40, 50], step: 5 });
const end = thumbs(root)[1]!;
for (let i = 0; i < 5; i += 1) {
press(end, 'ArrowLeft');
}
expect((slider.getValue() as [number, number])[1]).toBe(40);
});
it('orders the initial range pair', () => {
const root = setup(RANGE);
const slider = new Slider(root, { value: [70, 30] });
expect(slider.getValue()).toEqual([30, 70]);
});
it('emits slider:change with value and values', () => {
const root = setup(SINGLE);
const slider = new Slider(root, { value: 10, step: 5 });
const spy = vi.fn();
slider.on(SLIDER_CHANGE_EVENT, spy);
press(thumbs(root)[0]!, 'ArrowRight');
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].detail).toEqual({ value: 15, values: [15] });
});
it('setValue updates programmatically', () => {
const root = setup(RANGE);
const slider = new Slider(root, { value: [10, 90] });
slider.setValue([25, 75]);
expect(slider.getValue()).toEqual([25, 75]);
});
it('updates value on pointer drag (with a measured track)', () => {
const root = setup(SINGLE);
const track = root.querySelector('[data-c42-slider-track]')!;
vi.spyOn(track, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 200,
top: 0,
bottom: 10,
width: 200,
height: 10,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect);
const slider = new Slider(root, { value: 0, step: 1 });
track.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100 }));
expect(slider.getValue()).toBe(50);
document.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 150 }));
expect(slider.getValue()).toBe(75);
document.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }));
});
it('throws when the track is missing', () => {
document.body.innerHTML = '';
const root = document.body.firstElementChild as HTMLElement;
expect(() => new Slider(root)).toThrow(/data-c42-slider-track/);
});
it('throws when no thumb is present', () => {
document.body.innerHTML = '';
const root = document.body.firstElementChild as HTMLElement;
expect(() => new Slider(root)).toThrow(/data-c42-slider-thumb/);
});
it('removes listeners on destroy', () => {
const root = setup(SINGLE);
const slider = new Slider(root, { value: 50, step: 5 });
const spy = vi.fn();
slider.on(SLIDER_CHANGE_EVENT, spy);
slider.destroy();
press(thumbs(root)[0]!, 'ArrowRight');
expect(spy).not.toHaveBeenCalled();
});
});