parent
3e1cd3148d
commit
c3108b6903
@ -0,0 +1,319 @@
|
|||||||
|
// @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 {
|
||||||
|
TagsInputClearTriggerProvider,
|
||||||
|
TagsInputControlProvider,
|
||||||
|
TagsInputInputProvider,
|
||||||
|
TagsInputItemDeleteTriggerProvider,
|
||||||
|
TagsInputItemProvider,
|
||||||
|
TagsInputItemTextProvider,
|
||||||
|
TagsInputProvider
|
||||||
|
} from './tags-input-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,
|
||||||
|
langs: {
|
||||||
|
ts: vi.fn((key: string) => (key.includes('clear') ? 'Clear all tags' : 'Remove tag'))
|
||||||
|
},
|
||||||
|
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(TagsInputProvider.ctx, 'set').mockImplementation((value) => value);
|
||||||
|
vi.spyOn(TagsInputItemProvider.ctx, 'set').mockImplementation((value) => value);
|
||||||
|
|
||||||
|
return { dom };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tagsInputOpts(root = document.createElement('div')) {
|
||||||
|
return {
|
||||||
|
id: state('tags-input-root'),
|
||||||
|
ref: state<HTMLElement | null>(root),
|
||||||
|
value: state<string[]>(['alpha', 'beta']),
|
||||||
|
inputValue: state(''),
|
||||||
|
max: state(5),
|
||||||
|
delimiter: state(','),
|
||||||
|
addOnPaste: state(true),
|
||||||
|
blurBehavior: state<'add' | 'clear'>('add'),
|
||||||
|
allowDuplicates: state(false),
|
||||||
|
disabled: state(false),
|
||||||
|
readonly: state(false),
|
||||||
|
invalid: state(false),
|
||||||
|
placeholder: state('Add tag'),
|
||||||
|
validate: state((value: string) => value !== 'bad'),
|
||||||
|
onValueInvalid: state(vi.fn())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemOpts(index: number, value: string, el: HTMLElement) {
|
||||||
|
return {
|
||||||
|
id: state(`tag-${value}`),
|
||||||
|
ref: state<HTMLElement | null>(el),
|
||||||
|
index: state(index),
|
||||||
|
value: state(value),
|
||||||
|
disabled: state(false)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRenderedItem(el: HTMLElement, id: string) {
|
||||||
|
el.id = id;
|
||||||
|
el.setAttribute('data-tags-input-item', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyEvent(key: string, currentTarget: HTMLElement) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
currentTarget,
|
||||||
|
preventDefault: vi.fn()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputEvent(input: HTMLInputElement) {
|
||||||
|
return {
|
||||||
|
currentTarget: input
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pasteEvent(text: string) {
|
||||||
|
return {
|
||||||
|
preventDefault: vi.fn(),
|
||||||
|
clipboardData: {
|
||||||
|
getData: vi.fn(() => text)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TagsInputProvider', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds tags, rejects invalid values and projects root/control/input props', async () => {
|
||||||
|
const { dom } = installSomaHarness();
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const controlEl = document.createElement('div');
|
||||||
|
const inputEl = document.createElement('input');
|
||||||
|
root.append(controlEl, inputEl);
|
||||||
|
document.body.appendChild(root);
|
||||||
|
const opts = tagsInputOpts(root);
|
||||||
|
|
||||||
|
const { result, cleanup } = withEffectRoot(() => {
|
||||||
|
const provider = TagsInputProvider.create(opts);
|
||||||
|
vi.spyOn(TagsInputProvider, 'require').mockReturnValue(provider);
|
||||||
|
const control = TagsInputControlProvider.create({
|
||||||
|
id: state('tags-control'),
|
||||||
|
ref: state<HTMLElement | null>(controlEl)
|
||||||
|
});
|
||||||
|
const input = TagsInputInputProvider.create({
|
||||||
|
id: state('tags-input'),
|
||||||
|
ref: state<HTMLInputElement | null>(inputEl)
|
||||||
|
});
|
||||||
|
provider.inputRef.current = inputEl;
|
||||||
|
return { provider, control, input };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.provider.isEmpty).toBe(false);
|
||||||
|
expect(result.provider.isAtMax).toBe(false);
|
||||||
|
expect(result.input.props).toMatchObject({
|
||||||
|
id: 'tags-input',
|
||||||
|
type: 'text',
|
||||||
|
role: 'combobox',
|
||||||
|
'aria-autocomplete': 'list',
|
||||||
|
value: '',
|
||||||
|
placeholder: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.provider.addTag(' gamma ')).toBe(true);
|
||||||
|
expect(opts.value.current).toEqual(['alpha', 'beta', 'gamma']);
|
||||||
|
expect(result.provider.addTag('beta')).toBe(false);
|
||||||
|
expect(opts.onValueInvalid.current).toHaveBeenCalledWith('beta');
|
||||||
|
expect(result.provider.addTag('bad')).toBe(false);
|
||||||
|
expect(opts.onValueInvalid.current).toHaveBeenCalledWith('bad');
|
||||||
|
|
||||||
|
opts.inputValue.current = 'delta,epsilon';
|
||||||
|
result.provider.addFromInput();
|
||||||
|
expect(opts.value.current).toEqual(['alpha', 'beta', 'gamma', 'delta', 'epsilon']);
|
||||||
|
expect(opts.inputValue.current).toBe('');
|
||||||
|
expect(result.provider.isAtMax).toBe(true);
|
||||||
|
|
||||||
|
await tick();
|
||||||
|
expect(root.getAttribute('role')).toBe('listbox');
|
||||||
|
expect(root.getAttribute('aria-orientation')).toBe('horizontal');
|
||||||
|
expect(root.hasAttribute('data-empty')).toBe(false);
|
||||||
|
expect(controlEl.getAttribute('role')).toBe('listbox');
|
||||||
|
expect(controlEl.getAttribute('data-focus')).toBe('');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
dom.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles input keyboard, paste and blur behavior', () => {
|
||||||
|
const { dom } = installSomaHarness();
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const inputEl = document.createElement('input');
|
||||||
|
root.appendChild(inputEl);
|
||||||
|
document.body.appendChild(root);
|
||||||
|
const opts = tagsInputOpts(root);
|
||||||
|
opts.value.current = ['alpha'];
|
||||||
|
opts.inputValue.current = '';
|
||||||
|
|
||||||
|
const { result, cleanup } = withEffectRoot(() => {
|
||||||
|
const provider = TagsInputProvider.create(opts);
|
||||||
|
vi.spyOn(TagsInputProvider, 'require').mockReturnValue(provider);
|
||||||
|
const input = TagsInputInputProvider.create({
|
||||||
|
id: state('tags-input'),
|
||||||
|
ref: state<HTMLInputElement | null>(inputEl)
|
||||||
|
});
|
||||||
|
provider.inputRef.current = inputEl;
|
||||||
|
return { provider, input };
|
||||||
|
});
|
||||||
|
|
||||||
|
inputEl.value = 'beta,';
|
||||||
|
result.input.oninput(inputEvent(inputEl) as never);
|
||||||
|
expect(opts.value.current).toEqual(['alpha', 'beta']);
|
||||||
|
expect(opts.inputValue.current).toBe('');
|
||||||
|
|
||||||
|
const paste = pasteEvent('gamma,delta,eps');
|
||||||
|
result.input.onpaste(paste as never);
|
||||||
|
expect(paste.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(opts.value.current).toEqual(['alpha', 'beta', 'gamma', 'delta']);
|
||||||
|
expect(opts.inputValue.current).toBe('eps');
|
||||||
|
|
||||||
|
const enter = keyEvent('Enter', inputEl);
|
||||||
|
result.input.onkeydown(enter as never);
|
||||||
|
expect(enter.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(opts.value.current).toEqual(['alpha', 'beta', 'gamma', 'delta', 'eps']);
|
||||||
|
expect(opts.inputValue.current).toBe('');
|
||||||
|
|
||||||
|
const backspace = keyEvent('Backspace', inputEl);
|
||||||
|
result.input.onkeydown(backspace as never);
|
||||||
|
expect(backspace.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(opts.value.current).toEqual(['alpha', 'beta', 'gamma', 'delta']);
|
||||||
|
|
||||||
|
inputEl.selectionStart = 0;
|
||||||
|
inputEl.selectionEnd = 0;
|
||||||
|
const left = keyEvent('ArrowLeft', inputEl);
|
||||||
|
result.input.onkeydown(left as never);
|
||||||
|
expect(left.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(result.provider.highlightedIndex).toBe(3);
|
||||||
|
|
||||||
|
opts.inputValue.current = 'zeta';
|
||||||
|
result.input.onblur({ relatedTarget: null } as FocusEvent);
|
||||||
|
expect(opts.value.current).toEqual(['alpha', 'beta', 'gamma', 'delta', 'zeta']);
|
||||||
|
expect(result.provider.highlightedIndex).toBe(-1);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
dom.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates items and exposes delete/clear trigger props', () => {
|
||||||
|
const { dom } = installSomaHarness();
|
||||||
|
const focusSpy = vi.spyOn(dom, 'focus');
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const inputEl = document.createElement('input');
|
||||||
|
const firstEl = document.createElement('div');
|
||||||
|
const secondEl = document.createElement('div');
|
||||||
|
const textEl = document.createElement('span');
|
||||||
|
const deleteEl = document.createElement('button');
|
||||||
|
const clearEl = document.createElement('button');
|
||||||
|
markRenderedItem(firstEl, 'tag-alpha');
|
||||||
|
markRenderedItem(secondEl, 'tag-beta');
|
||||||
|
root.append(firstEl, secondEl, inputEl, textEl, deleteEl, clearEl);
|
||||||
|
document.body.appendChild(root);
|
||||||
|
const opts = tagsInputOpts(root);
|
||||||
|
|
||||||
|
const { result, cleanup } = withEffectRoot(() => {
|
||||||
|
const provider = TagsInputProvider.create(opts);
|
||||||
|
vi.spyOn(TagsInputProvider, 'require').mockReturnValue(provider);
|
||||||
|
provider.inputRef.current = inputEl;
|
||||||
|
const first = TagsInputItemProvider.create(itemOpts(0, 'alpha', firstEl));
|
||||||
|
const second = TagsInputItemProvider.create(itemOpts(1, 'beta', secondEl));
|
||||||
|
vi.spyOn(TagsInputItemProvider, 'require').mockReturnValue(second);
|
||||||
|
const text = TagsInputItemTextProvider.create({
|
||||||
|
id: state('tag-beta-text'),
|
||||||
|
ref: state<HTMLElement | null>(textEl)
|
||||||
|
});
|
||||||
|
const deleteTrigger = TagsInputItemDeleteTriggerProvider.create({
|
||||||
|
id: state('tag-beta-delete'),
|
||||||
|
ref: state<HTMLElement | null>(deleteEl)
|
||||||
|
});
|
||||||
|
const clearTrigger = TagsInputClearTriggerProvider.create({
|
||||||
|
id: state('tags-clear'),
|
||||||
|
ref: state<HTMLElement | null>(clearEl)
|
||||||
|
});
|
||||||
|
return { provider, first, second, text, deleteTrigger, clearTrigger };
|
||||||
|
});
|
||||||
|
|
||||||
|
result.second.onclick({ currentTarget: secondEl } as never);
|
||||||
|
expect(result.provider.highlightedIndex).toBe(1);
|
||||||
|
expect(focusSpy).toHaveBeenLastCalledWith(secondEl);
|
||||||
|
expect(result.second.props).toMatchObject({
|
||||||
|
id: 'tag-beta',
|
||||||
|
role: 'option',
|
||||||
|
'aria-selected': true,
|
||||||
|
tabindex: 0,
|
||||||
|
'data-state': 'active',
|
||||||
|
'data-highlighted': ''
|
||||||
|
});
|
||||||
|
expect(result.text.props).toMatchObject({
|
||||||
|
id: 'tag-beta-text',
|
||||||
|
'data-state': 'active'
|
||||||
|
});
|
||||||
|
|
||||||
|
const left = keyEvent('ArrowLeft', secondEl);
|
||||||
|
result.second.onkeydown(left as never);
|
||||||
|
expect(left.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(result.provider.highlightedIndex).toBe(0);
|
||||||
|
|
||||||
|
const escape = keyEvent('Escape', firstEl);
|
||||||
|
result.first.onkeydown(escape as never);
|
||||||
|
expect(escape.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(result.provider.highlightedIndex).toBe(-1);
|
||||||
|
expect(focusSpy).toHaveBeenLastCalledWith(inputEl);
|
||||||
|
|
||||||
|
result.second.onclick({ currentTarget: secondEl } as never);
|
||||||
|
const deleteClick = { stopPropagation: vi.fn() };
|
||||||
|
result.deleteTrigger.onclick(deleteClick as never);
|
||||||
|
expect(deleteClick.stopPropagation).toHaveBeenCalledOnce();
|
||||||
|
expect(opts.value.current).toEqual(['alpha']);
|
||||||
|
expect(focusSpy).toHaveBeenLastCalledWith(inputEl);
|
||||||
|
expect(result.deleteTrigger.props).toMatchObject({
|
||||||
|
type: 'button',
|
||||||
|
tabindex: -1,
|
||||||
|
'aria-label': 'Remove tag'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.clearTrigger.props.hidden).toBeUndefined();
|
||||||
|
result.clearTrigger.onclick({ currentTarget: clearEl } as never);
|
||||||
|
expect(opts.value.current).toEqual([]);
|
||||||
|
expect(result.clearTrigger.props.hidden).toBe(true);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
dom.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in new issue