parent
9975ba8631
commit
41da8a2b26
@ -0,0 +1,288 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state } from '$libs/reactive';
|
||||
import type { Morfo } from '$uix/morfo';
|
||||
import { Soma } from '$soma/core/soma.svelte';
|
||||
import { createSomaRuntime, type SomaRuntimeSources } from '$soma/runtime.svelte';
|
||||
|
||||
import {
|
||||
SliderProvider,
|
||||
SliderRangeProvider,
|
||||
SliderThumbProvider,
|
||||
SliderTickProvider
|
||||
} from './slider-provider.svelte';
|
||||
|
||||
function withEffectRoot<T>(fn: () => T): { result: T; cleanup: () => void } {
|
||||
let result!: T;
|
||||
const cleanup = $effect.root(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, cleanup };
|
||||
}
|
||||
|
||||
function installSomaHarness() {
|
||||
const dom = createActiveDom();
|
||||
const soma = {
|
||||
dom,
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => key,
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(SliderProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function createSliderRoot() {
|
||||
const root = document.createElement('div');
|
||||
root.getBoundingClientRect = vi.fn(
|
||||
() =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 20,
|
||||
width: 100,
|
||||
height: 20,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
);
|
||||
root.setPointerCapture = vi.fn();
|
||||
root.releasePointerCapture = vi.fn();
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function sliderOpts(root = createSliderRoot()) {
|
||||
return {
|
||||
id: state('slider-root'),
|
||||
ref: state<HTMLElement | null>(root),
|
||||
value: state([25, 75]),
|
||||
type: state<'single' | 'multiple'>('multiple'),
|
||||
min: state(0),
|
||||
max: state(100),
|
||||
step: state(5),
|
||||
orientation: state<'horizontal' | 'vertical'>('horizontal'),
|
||||
disabled: state(false),
|
||||
dir: state<'ltr' | 'rtl'>('ltr'),
|
||||
name: state('price'),
|
||||
thumbLabels: state<string[] | undefined>(['Minimum', 'Maximum']),
|
||||
onValueCommit: state<(value: number[]) => void>(() => undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function pointerEvent(clientX: number, clientY: number, pointerId = 1): PointerEvent {
|
||||
return { clientX, clientY, pointerId } as unknown as PointerEvent;
|
||||
}
|
||||
|
||||
function keyEvent(key: string): KeyboardEvent {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as KeyboardEvent;
|
||||
}
|
||||
|
||||
describe('SliderProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('exposes provider, range, thumb and tick contracts', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = createSliderRoot();
|
||||
const rangeEl = document.createElement('span');
|
||||
const thumbEl = document.createElement('span');
|
||||
const tickEl = document.createElement('span');
|
||||
const opts = sliderOpts(root);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = SliderProvider.create(opts);
|
||||
vi.spyOn(SliderProvider, 'require').mockReturnValue(provider);
|
||||
const range = SliderRangeProvider.create({
|
||||
id: state('slider-range'),
|
||||
ref: state<HTMLElement | null>(rangeEl)
|
||||
});
|
||||
const thumb = SliderThumbProvider.create({
|
||||
id: state('slider-thumb-0'),
|
||||
ref: state<HTMLElement | null>(thumbEl),
|
||||
index: state(0)
|
||||
});
|
||||
const tick = SliderTickProvider.create({
|
||||
id: state('slider-tick-50'),
|
||||
ref: state<HTMLElement | null>(tickEl),
|
||||
value: state(50)
|
||||
});
|
||||
return { provider, range, thumb, tick };
|
||||
});
|
||||
|
||||
expect(result.provider.props).toMatchObject({
|
||||
id: 'slider-root',
|
||||
'data-slider': '',
|
||||
dir: 'ltr'
|
||||
});
|
||||
expect(result.provider.props.style).toMatchObject({
|
||||
position: 'relative',
|
||||
'touch-action': 'pan-y'
|
||||
});
|
||||
expect(result.range.props).toMatchObject({
|
||||
id: 'slider-range',
|
||||
'data-slider-range': ''
|
||||
});
|
||||
expect(result.range.props.style).toMatchObject({
|
||||
position: 'absolute',
|
||||
left: '25%',
|
||||
width: '50%'
|
||||
});
|
||||
expect(result.thumb.props).toMatchObject({
|
||||
id: 'slider-thumb-0',
|
||||
'data-slider-thumb': '',
|
||||
role: 'slider',
|
||||
tabindex: 0,
|
||||
'aria-valuemin': 0,
|
||||
'aria-valuemax': 100,
|
||||
'aria-valuenow': 25,
|
||||
'aria-label': 'Minimum',
|
||||
'aria-orientation': 'horizontal'
|
||||
});
|
||||
expect(result.thumb.props.style).toMatchObject({
|
||||
position: 'absolute',
|
||||
left: '25%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
});
|
||||
expect(result.thumb.inputProps).toMatchObject({
|
||||
type: 'hidden',
|
||||
name: 'price[0]',
|
||||
value: 25,
|
||||
disabled: undefined
|
||||
});
|
||||
expect(result.tick.props.style).toMatchObject({
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)'
|
||||
});
|
||||
|
||||
await tick();
|
||||
expect(root.getAttribute('role')).toBe('group');
|
||||
expect(root.getAttribute('data-orientation')).toBe('horizontal');
|
||||
expect(tickEl.getAttribute('data-value')).toBe('50');
|
||||
expect(tickEl.getAttribute('data-active')).toBe('');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('snaps, clamps and prevents crossover for multiple thumbs', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = sliderOpts();
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => SliderProvider.create(opts));
|
||||
|
||||
provider.updateValue(0, 33);
|
||||
expect(opts.value.current).toEqual([35, 75]);
|
||||
|
||||
provider.updateValue(0, 120);
|
||||
expect(opts.value.current).toEqual([75, 75]);
|
||||
|
||||
opts.value.current = [25, 75];
|
||||
provider.updateValue(1, 10);
|
||||
expect(opts.value.current).toEqual([25, 25]);
|
||||
|
||||
opts.disabled.current = true;
|
||||
provider.updateValue(0, 50);
|
||||
expect(opts.value.current).toEqual([25, 25]);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('updates and commits value during confirmed pointer drag', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = createSliderRoot();
|
||||
const opts = sliderOpts(root);
|
||||
opts.value.current = [0];
|
||||
opts.type.current = 'single';
|
||||
const onValueCommit = vi.fn();
|
||||
opts.onValueCommit.current = onValueCommit;
|
||||
|
||||
const { result: provider, cleanup } = withEffectRoot(() => SliderProvider.create(opts));
|
||||
|
||||
provider.onpointerdown(pointerEvent(30, 10));
|
||||
expect(provider.activeThumbIndex).toBe(0);
|
||||
|
||||
provider.onpointermove(pointerEvent(32, 10));
|
||||
expect(opts.value.current).toEqual([0]);
|
||||
expect(root.setPointerCapture).not.toHaveBeenCalled();
|
||||
|
||||
provider.onpointermove(pointerEvent(50, 10));
|
||||
expect(root.setPointerCapture).toHaveBeenCalledWith(1);
|
||||
expect(opts.value.current).toEqual([50]);
|
||||
|
||||
provider.onpointerup(pointerEvent(50, 10));
|
||||
expect(root.releasePointerCapture).toHaveBeenCalledWith(1);
|
||||
expect(provider.activeThumbIndex).toBeNull();
|
||||
expect(onValueCommit).toHaveBeenCalledWith([50]);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('commits thumb keyboard changes and respects disabled state', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const opts = sliderOpts();
|
||||
opts.value.current = [50];
|
||||
opts.type.current = 'single';
|
||||
const onValueCommit = vi.fn();
|
||||
opts.onValueCommit.current = onValueCommit;
|
||||
|
||||
const { result: thumb, cleanup } = withEffectRoot(() => {
|
||||
const provider = SliderProvider.create(opts);
|
||||
vi.spyOn(SliderProvider, 'require').mockReturnValue(provider);
|
||||
return SliderThumbProvider.create({
|
||||
id: state('slider-thumb'),
|
||||
ref: state<HTMLElement | null>(document.createElement('span')),
|
||||
index: state(0)
|
||||
});
|
||||
});
|
||||
|
||||
const right = keyEvent('ArrowRight');
|
||||
thumb.onkeydown(right);
|
||||
expect(right.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(opts.value.current).toEqual([55]);
|
||||
expect(onValueCommit).toHaveBeenLastCalledWith([55]);
|
||||
|
||||
const pageDown = keyEvent('PageDown');
|
||||
thumb.onkeydown(pageDown);
|
||||
expect(opts.value.current).toEqual([5]);
|
||||
|
||||
const home = keyEvent('Home');
|
||||
thumb.onkeydown(home);
|
||||
expect(opts.value.current).toEqual([0]);
|
||||
|
||||
const end = keyEvent('End');
|
||||
thumb.onkeydown(end);
|
||||
expect(opts.value.current).toEqual([100]);
|
||||
|
||||
opts.disabled.current = true;
|
||||
const left = keyEvent('ArrowLeft');
|
||||
thumb.onkeydown(left);
|
||||
expect(left.preventDefault).not.toHaveBeenCalled();
|
||||
expect(opts.value.current).toEqual([100]);
|
||||
expect(thumb.props.tabindex).toBeUndefined();
|
||||
expect(thumb.inputProps.disabled).toBe(true);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue