Add tags input provider coverage

active-uix
dev 5 months ago
parent 3e1cd3148d
commit c3108b6903

@ -125,9 +125,11 @@ Actualizacion 2026-05-15:
regiones ARIA declarativas y uso standalone. regiones ARIA declarativas y uso standalone.
- Cobertura nueva para Feed: attrs APG feed/article/title, navegación - Cobertura nueva para Feed: attrs APG feed/article/title, navegación
PageUp/PageDown, threads anidados y sentinel IntersectionObserver. PageUp/PageDown, threads anidados y sentinel IntersectionObserver.
- Cobertura nueva para TagsInput: add/paste/blur, navegación de tags,
delete/clear triggers y props de input/control.
- Validado por fase: - Validado por fase:
- `npm run check` -> 0 errores, 0 warnings. - `npm run check` -> 0 errores, 0 warnings.
- `npm run test` -> 223 archivos, 2218 tests OK. - `npm run test` -> 224 archivos, 2221 tests OK.
- tests focales de Select/Popover/Toast/RangeCalendar -> 11 tests OK. - tests focales de Select/Popover/Toast/RangeCalendar -> 11 tests OK.
- tests focales Dialog/Drawer/Popover/Select -> 10 tests OK. - tests focales Dialog/Drawer/Popover/Select -> 10 tests OK.
- `command-provider.svelte.test.ts` -> 2 tests OK. - `command-provider.svelte.test.ts` -> 2 tests OK.
@ -179,6 +181,7 @@ Actualizacion 2026-05-15:
- `tag-group-provider.svelte.test.ts` -> 3 tests OK. - `tag-group-provider.svelte.test.ts` -> 3 tests OK.
- `announce-provider.svelte.test.ts` -> 3 tests OK. - `announce-provider.svelte.test.ts` -> 3 tests OK.
- `feed-provider.svelte.test.ts` -> 3 tests OK. - `feed-provider.svelte.test.ts` -> 3 tests OK.
- `tags-input-provider.svelte.test.ts` -> 3 tests OK.
- guardia `rg "from './.*provider\\.svelte'" src/uix/soma/components -g 'exports.ts'` - guardia `rg "from './.*provider\\.svelte'" src/uix/soma/components -g 'exports.ts'`
-> solo queda `select/components/select-provider.svelte`, que es wrapper -> solo queda `select/components/select-provider.svelte`, que es wrapper
publico, no runtime provider. publico, no runtime provider.

@ -557,6 +557,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A
timers UIX, regiones ARIA declarativas y uso standalone. timers UIX, regiones ARIA declarativas y uso standalone.
- `feed/feed-provider.svelte.test.ts` — attrs APG feed/article/title, - `feed/feed-provider.svelte.test.ts` — attrs APG feed/article/title,
navegación PageUp/PageDown, threads anidados y sentinel IntersectionObserver. navegación PageUp/PageDown, threads anidados y sentinel IntersectionObserver.
- `tags-input/tags-input-provider.svelte.test.ts` — add/paste/blur,
navegación de tags, delete/clear triggers y props de input/control.
Pendiente: seguir ampliando cobertura por el resto del catalogo Soma, ya por Pendiente: seguir ampliando cobertura por el resto del catalogo Soma, ya por
componentes de riesgo medio y familias menos centrales. `table-core`, componentes de riesgo medio y familias menos centrales. `table-core`,

@ -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…
Cancel
Save

Powered by TurnKey Linux.