parent
419ec80890
commit
c108d02512
@ -0,0 +1,274 @@
|
|||||||
|
// @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 { FieldProvider } from '$soma/components/field/field-provider.svelte';
|
||||||
|
|
||||||
|
import { RatingGroupItemProvider, RatingGroupProvider } from './rating-group-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(field: FieldProvider | undefined = undefined) {
|
||||||
|
const dom = createActiveDom();
|
||||||
|
const soma = {
|
||||||
|
dom,
|
||||||
|
langs: {
|
||||||
|
ts: vi.fn(() => 'Rating')
|
||||||
|
},
|
||||||
|
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(FieldProvider, 'get').mockReturnValue(field);
|
||||||
|
vi.spyOn(RatingGroupProvider.ctx, 'set').mockImplementation((value) => value);
|
||||||
|
|
||||||
|
return { dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingOpts(root = document.createElement('div')) {
|
||||||
|
return {
|
||||||
|
id: state('rating-root'),
|
||||||
|
ref: state<HTMLElement | null>(root),
|
||||||
|
value: state(2.5),
|
||||||
|
min: state(0),
|
||||||
|
max: state(5),
|
||||||
|
allowHalf: state(true),
|
||||||
|
hoverPreview: state(true),
|
||||||
|
clearable: state(true),
|
||||||
|
disabled: state(false),
|
||||||
|
readonly: state(false),
|
||||||
|
required: state(false),
|
||||||
|
invalid: state(false),
|
||||||
|
orientation: state<'horizontal' | 'vertical'>('horizontal'),
|
||||||
|
dir: state<'ltr' | 'rtl'>('ltr'),
|
||||||
|
name: state<string | undefined>('rating'),
|
||||||
|
ariaLabel: state<string | undefined>(undefined),
|
||||||
|
onValueChange: state<((value: number) => void) | undefined>(undefined)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemOpts(index: number, el: HTMLElement) {
|
||||||
|
return {
|
||||||
|
id: state(`rating-item-${index}`),
|
||||||
|
ref: state<HTMLElement | null>(el),
|
||||||
|
index: state(index),
|
||||||
|
disabled: state(false)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointerEvent(
|
||||||
|
el: HTMLElement,
|
||||||
|
overrides: Partial<PointerEvent> & {
|
||||||
|
clientX?: number;
|
||||||
|
clientY?: number;
|
||||||
|
pointerType?: string;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
currentTarget: el,
|
||||||
|
clientX: 0,
|
||||||
|
clientY: 0,
|
||||||
|
pointerType: 'mouse',
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyEvent(key: string) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
preventDefault: vi.fn()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeField() {
|
||||||
|
return {
|
||||||
|
get isDisabled() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
get isReadonly() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
get isRequired() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
get isInvalid() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} as FieldProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RatingGroupProvider', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects slider attrs, Field flags and item states', async () => {
|
||||||
|
const { dom } = installSomaHarness(fakeField());
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const activeEl = document.createElement('span');
|
||||||
|
const partialEl = document.createElement('span');
|
||||||
|
document.body.append(root, activeEl, partialEl);
|
||||||
|
const opts = ratingOpts(root);
|
||||||
|
|
||||||
|
const { result, cleanup } = withEffectRoot(() => {
|
||||||
|
const provider = RatingGroupProvider.create(opts);
|
||||||
|
vi.spyOn(RatingGroupProvider, 'require').mockReturnValue(provider);
|
||||||
|
const active = RatingGroupItemProvider.create(itemOpts(1, activeEl));
|
||||||
|
const partial = RatingGroupItemProvider.create(itemOpts(2, partialEl));
|
||||||
|
return { provider, active, partial };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.provider.items).toEqual([
|
||||||
|
{ index: 0, state: 'active' },
|
||||||
|
{ index: 1, state: 'active' },
|
||||||
|
{ index: 2, state: 'partial' },
|
||||||
|
{ index: 3, state: 'inactive' },
|
||||||
|
{ index: 4, state: 'inactive' }
|
||||||
|
]);
|
||||||
|
expect(result.provider.snippetProps).toMatchObject({
|
||||||
|
value: 2.5,
|
||||||
|
min: 0,
|
||||||
|
max: 5,
|
||||||
|
isFocused: false,
|
||||||
|
isHovering: false
|
||||||
|
});
|
||||||
|
expect(result.provider.props.tabindex).toBe(0);
|
||||||
|
expect(result.active.snippetProps).toEqual({ index: 1, state: 'active' });
|
||||||
|
expect(result.partial.snippetProps).toEqual({ index: 2, state: 'partial' });
|
||||||
|
|
||||||
|
result.provider.onfocus();
|
||||||
|
await tick();
|
||||||
|
expect(root.getAttribute('role')).toBe('slider');
|
||||||
|
expect(root.getAttribute('aria-label')).toBe('Rating');
|
||||||
|
expect(root.getAttribute('aria-valuenow')).toBe('2.5');
|
||||||
|
expect(root.getAttribute('aria-valuemin')).toBe('0');
|
||||||
|
expect(root.getAttribute('aria-valuemax')).toBe('5');
|
||||||
|
expect(root.getAttribute('data-required')).toBe('');
|
||||||
|
expect(root.getAttribute('data-invalid')).toBe('');
|
||||||
|
expect(root.getAttribute('data-focused')).toBe('');
|
||||||
|
expect(activeEl.getAttribute('role')).toBe('presentation');
|
||||||
|
expect(activeEl.getAttribute('data-state')).toBe('active');
|
||||||
|
expect(activeEl.getAttribute('data-value')).toBe('2');
|
||||||
|
expect(partialEl.getAttribute('data-state')).toBe('partial');
|
||||||
|
expect(partialEl.getAttribute('data-value')).toBe('3');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
dom.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previews half values, commits clicks and clears the current value', () => {
|
||||||
|
const { dom } = installSomaHarness();
|
||||||
|
const focusSpy = vi.spyOn(dom, 'focus');
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const itemEl = document.createElement('span');
|
||||||
|
itemEl.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 20 }) as DOMRect;
|
||||||
|
document.body.append(root, itemEl);
|
||||||
|
const opts = ratingOpts(root);
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
opts.onValueChange.current = onValueChange;
|
||||||
|
|
||||||
|
const { result, cleanup } = withEffectRoot(() => {
|
||||||
|
const provider = RatingGroupProvider.create(opts);
|
||||||
|
vi.spyOn(RatingGroupProvider, 'require').mockReturnValue(provider);
|
||||||
|
const item = RatingGroupItemProvider.create(itemOpts(2, itemEl));
|
||||||
|
return { provider, item };
|
||||||
|
});
|
||||||
|
|
||||||
|
result.item.onpointermove(pointerEvent(itemEl, { clientX: 25 }) as never);
|
||||||
|
expect(result.provider.hoverValue).toBe(2.5);
|
||||||
|
expect(result.provider.items[2].state).toBe('partial');
|
||||||
|
|
||||||
|
result.item.onpointermove(pointerEvent(itemEl, { clientX: 75, pointerType: 'touch' }) as never);
|
||||||
|
expect(result.provider.hoverValue).toBe(2.5);
|
||||||
|
|
||||||
|
result.provider.onpointerleave();
|
||||||
|
expect(result.provider.hoverValue).toBeNull();
|
||||||
|
|
||||||
|
result.item.onclick(pointerEvent(itemEl, { clientX: 75 }) as never);
|
||||||
|
expect(opts.value.current).toBe(3);
|
||||||
|
expect(onValueChange).toHaveBeenLastCalledWith(3);
|
||||||
|
expect(focusSpy).toHaveBeenCalledWith(root);
|
||||||
|
|
||||||
|
result.item.onclick(pointerEvent(itemEl, { clientX: 75 }) as never);
|
||||||
|
expect(opts.value.current).toBe(0);
|
||||||
|
expect(onValueChange).toHaveBeenLastCalledWith(0);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
dom.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resizes with keyboard and guards disabled or readonly state', () => {
|
||||||
|
const { dom } = installSomaHarness();
|
||||||
|
const root = document.createElement('div');
|
||||||
|
document.body.appendChild(root);
|
||||||
|
const opts = ratingOpts(root);
|
||||||
|
opts.value.current = 2;
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
opts.onValueChange.current = onValueChange;
|
||||||
|
|
||||||
|
const { result, cleanup } = withEffectRoot(() => RatingGroupProvider.create(opts));
|
||||||
|
|
||||||
|
const next = keyEvent('ArrowRight');
|
||||||
|
result.onkeydown(next as never);
|
||||||
|
expect(next.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(opts.value.current).toBe(2.5);
|
||||||
|
|
||||||
|
const prev = keyEvent('ArrowLeft');
|
||||||
|
result.onkeydown(prev as never);
|
||||||
|
expect(prev.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(opts.value.current).toBe(2);
|
||||||
|
|
||||||
|
const home = keyEvent('Home');
|
||||||
|
result.onkeydown(home as never);
|
||||||
|
expect(home.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(opts.value.current).toBe(0);
|
||||||
|
|
||||||
|
const end = keyEvent('End');
|
||||||
|
result.onkeydown(end as never);
|
||||||
|
expect(end.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(opts.value.current).toBe(5);
|
||||||
|
|
||||||
|
const numeric = keyEvent('4');
|
||||||
|
result.onkeydown(numeric as never);
|
||||||
|
expect(numeric.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(opts.value.current).toBe(4);
|
||||||
|
|
||||||
|
opts.dir.current = 'rtl';
|
||||||
|
const rtlLeft = keyEvent('ArrowLeft');
|
||||||
|
result.onkeydown(rtlLeft as never);
|
||||||
|
expect(rtlLeft.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(opts.value.current).toBe(4.5);
|
||||||
|
|
||||||
|
opts.readonly.current = true;
|
||||||
|
result.onkeydown(keyEvent('ArrowLeft') as never);
|
||||||
|
expect(opts.value.current).toBe(4.5);
|
||||||
|
|
||||||
|
opts.readonly.current = false;
|
||||||
|
opts.disabled.current = true;
|
||||||
|
expect(result.props.tabindex).toBe(-1);
|
||||||
|
result.onkeydown(keyEvent('End') as never);
|
||||||
|
expect(opts.value.current).toBe(4.5);
|
||||||
|
expect(onValueChange).toHaveBeenLastCalledWith(4.5);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
dom.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in new issue