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