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