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