diff --git a/continue.md b/continue.md index 2513903fc..1347d1125 100644 --- a/continue.md +++ b/continue.md @@ -125,9 +125,11 @@ Actualizacion 2026-05-15: regiones ARIA declarativas y uso standalone. - Cobertura nueva para Feed: attrs APG feed/article/title, navegación 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: - `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 Dialog/Drawer/Popover/Select -> 10 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. - `announce-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'` -> solo queda `select/components/select-provider.svelte`, que es wrapper publico, no runtime provider. diff --git a/src/uix/soma/SOMA_ARCHITECTURE.md b/src/uix/soma/SOMA_ARCHITECTURE.md index 88fb259f1..34ad8a60c 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -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. - `feed/feed-provider.svelte.test.ts` — attrs APG feed/article/title, 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 componentes de riesgo medio y familias menos centrales. `table-core`, diff --git a/src/uix/soma/components/tags-input/tags-input-provider.svelte.test.ts b/src/uix/soma/components/tags-input/tags-input-provider.svelte.test.ts new file mode 100644 index 000000000..0d826af57 --- /dev/null +++ b/src/uix/soma/components/tags-input/tags-input-provider.svelte.test.ts @@ -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(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) => + 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(root), + value: state(['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(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(controlEl) + }); + const input = TagsInputInputProvider.create({ + id: state('tags-input'), + ref: state(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(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(textEl) + }); + const deleteTrigger = TagsInputItemDeleteTriggerProvider.create({ + id: state('tag-beta-delete'), + ref: state(deleteEl) + }); + const clearTrigger = TagsInputClearTriggerProvider.create({ + id: state('tags-clear'), + ref: state(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(); + }); +});