2026-04-17 20:10:17 +03:00
|
|
|
import {
|
2026-06-02 11:02:52 +03:00
|
|
|
fireEvent,
|
2026-04-17 20:10:17 +03:00
|
|
|
render,
|
|
|
|
|
screen,
|
|
|
|
|
} from '@testing-library/svelte';
|
2026-06-02 11:02:52 +03:00
|
|
|
import {
|
|
|
|
|
beforeAll,
|
|
|
|
|
vi,
|
|
|
|
|
} from 'vitest';
|
2026-04-17 20:10:17 +03:00
|
|
|
import Slider from './Slider.svelte';
|
|
|
|
|
|
2026-06-02 11:02:52 +03:00
|
|
|
// jsdom lacks PointerEvent; back it with MouseEvent so clientX/clientY survive.
|
|
|
|
|
beforeAll(() => {
|
|
|
|
|
if (typeof PointerEvent === 'undefined') {
|
|
|
|
|
class PointerEventPolyfill extends MouseEvent {
|
|
|
|
|
pointerId: number;
|
|
|
|
|
constructor(type: string, params: PointerEventInit = {}) {
|
|
|
|
|
super(type, params);
|
|
|
|
|
this.pointerId = params.pointerId ?? 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// @ts-expect-error assigning polyfill to global
|
|
|
|
|
global.PointerEvent = PointerEventPolyfill;
|
|
|
|
|
}
|
|
|
|
|
HTMLElement.prototype.setPointerCapture = vi.fn();
|
|
|
|
|
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-17 20:10:17 +03:00
|
|
|
describe('Slider', () => {
|
|
|
|
|
describe('Rendering', () => {
|
|
|
|
|
it('renders a slider element', () => {
|
|
|
|
|
render(Slider);
|
|
|
|
|
expect(screen.getByRole('slider')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('displays formatted value', () => {
|
|
|
|
|
render(Slider, { value: 50 });
|
|
|
|
|
expect(screen.getByText('50')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('applies a custom formatter', () => {
|
|
|
|
|
const { container } = render(Slider, { value: 25, format: (v: number) => `${v}%` });
|
|
|
|
|
expect(container.textContent).toContain('25%');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Props', () => {
|
|
|
|
|
it('respects min and max attributes', () => {
|
|
|
|
|
render(Slider, { min: 10, max: 90, value: 50 });
|
|
|
|
|
const slider = screen.getByRole('slider');
|
|
|
|
|
expect(slider).toHaveAttribute('aria-valuemin', '10');
|
|
|
|
|
expect(slider).toHaveAttribute('aria-valuemax', '90');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('reflects value as aria-valuenow', () => {
|
|
|
|
|
render(Slider, { value: 42 });
|
|
|
|
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '42');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is disabled when disabled=true', () => {
|
|
|
|
|
render(Slider, { disabled: true });
|
|
|
|
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is not disabled by default', () => {
|
|
|
|
|
render(Slider, { value: 0 });
|
|
|
|
|
expect(screen.getByRole('slider')).not.toHaveAttribute('aria-disabled', 'true');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Orientations', () => {
|
|
|
|
|
it('renders horizontal by default', () => {
|
|
|
|
|
const { container } = render(Slider);
|
|
|
|
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-orientation', 'horizontal');
|
|
|
|
|
expect(container.querySelector('.cursor-col-resize')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('renders vertical when orientation="vertical"', () => {
|
|
|
|
|
const { container } = render(Slider, { orientation: 'vertical' });
|
|
|
|
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-orientation', 'vertical');
|
|
|
|
|
expect(container.querySelector('.cursor-row-resize')).toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-02 11:02:52 +03:00
|
|
|
|
|
|
|
|
describe('Keyboard', () => {
|
|
|
|
|
it('increments by step on ArrowRight / ArrowUp', async () => {
|
|
|
|
|
const onValueChange = vi.fn();
|
|
|
|
|
render(Slider, { value: 50, step: 5, onValueChange });
|
|
|
|
|
const thumb = screen.getByRole('slider');
|
|
|
|
|
await fireEvent.keyDown(thumb, { key: 'ArrowRight' });
|
|
|
|
|
expect(thumb).toHaveAttribute('aria-valuenow', '55');
|
|
|
|
|
expect(onValueChange).toHaveBeenCalledWith(55);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('decrements by step on ArrowLeft / ArrowDown', async () => {
|
|
|
|
|
render(Slider, { value: 50, step: 5 });
|
|
|
|
|
const thumb = screen.getByRole('slider');
|
|
|
|
|
await fireEvent.keyDown(thumb, { key: 'ArrowDown' });
|
|
|
|
|
expect(thumb).toHaveAttribute('aria-valuenow', '45');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('jumps to min on Home and max on End', async () => {
|
|
|
|
|
render(Slider, { value: 50, min: 10, max: 90 });
|
|
|
|
|
const thumb = screen.getByRole('slider');
|
|
|
|
|
await fireEvent.keyDown(thumb, { key: 'Home' });
|
|
|
|
|
expect(thumb).toHaveAttribute('aria-valuenow', '10');
|
|
|
|
|
await fireEvent.keyDown(thumb, { key: 'End' });
|
|
|
|
|
expect(thumb).toHaveAttribute('aria-valuenow', '90');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('moves by step*10 on PageUp / PageDown', async () => {
|
|
|
|
|
render(Slider, { value: 50, step: 2 });
|
|
|
|
|
const thumb = screen.getByRole('slider');
|
|
|
|
|
await fireEvent.keyDown(thumb, { key: 'PageUp' });
|
|
|
|
|
expect(thumb).toHaveAttribute('aria-valuenow', '70');
|
|
|
|
|
await fireEvent.keyDown(thumb, { key: 'PageDown' });
|
|
|
|
|
expect(thumb).toHaveAttribute('aria-valuenow', '50');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('clamps at the bounds', async () => {
|
|
|
|
|
render(Slider, { value: 98, max: 100, step: 5 });
|
|
|
|
|
const thumb = screen.getByRole('slider');
|
|
|
|
|
await fireEvent.keyDown(thumb, { key: 'End' });
|
|
|
|
|
expect(thumb).toHaveAttribute('aria-valuenow', '100');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does nothing when disabled', async () => {
|
|
|
|
|
const onValueChange = vi.fn();
|
|
|
|
|
render(Slider, { value: 50, disabled: true, onValueChange });
|
|
|
|
|
const thumb = screen.getByRole('slider');
|
|
|
|
|
await fireEvent.keyDown(thumb, { key: 'ArrowRight' });
|
|
|
|
|
expect(thumb).toHaveAttribute('aria-valuenow', '50');
|
|
|
|
|
expect(onValueChange).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Pointer', () => {
|
|
|
|
|
/**
|
|
|
|
|
* Force a deterministic track rect since jsdom has no layout.
|
|
|
|
|
*/
|
|
|
|
|
function mockTrackRect(container: HTMLElement) {
|
|
|
|
|
const track = container.querySelector('[role="presentation"]') as HTMLElement;
|
|
|
|
|
track.getBoundingClientRect = () =>
|
|
|
|
|
({ left: 0, right: 200, top: 0, bottom: 20, width: 200, height: 20 }) as DOMRect;
|
|
|
|
|
return track;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('seeks to the clicked position (click-to-seek)', async () => {
|
|
|
|
|
const onValueChange = vi.fn();
|
|
|
|
|
const { container } = render(Slider, { value: 0, min: 0, max: 100, onValueChange });
|
|
|
|
|
const track = mockTrackRect(container);
|
|
|
|
|
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
|
|
|
|
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '50');
|
|
|
|
|
expect(onValueChange).toHaveBeenCalledWith(50);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('updates while dragging after pointerdown', async () => {
|
|
|
|
|
const { container } = render(Slider, { value: 0, min: 0, max: 100 });
|
|
|
|
|
const track = mockTrackRect(container);
|
|
|
|
|
await fireEvent.pointerDown(track, { clientX: 50, clientY: 10, pointerId: 1 });
|
|
|
|
|
await fireEvent.pointerMove(track, { clientX: 150, clientY: 10, pointerId: 1 });
|
|
|
|
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '75');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('ignores pointer when disabled', async () => {
|
|
|
|
|
const { container } = render(Slider, { value: 0, disabled: true });
|
|
|
|
|
const track = mockTrackRect(container);
|
|
|
|
|
await fireEvent.pointerDown(track, { clientX: 100, clientY: 10, pointerId: 1 });
|
|
|
|
|
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0');
|
|
|
|
|
});
|
|
|
|
|
});
|