diff --git a/continue.md b/continue.md index 306102e3f..d177f13fa 100644 --- a/continue.md +++ b/continue.md @@ -119,9 +119,11 @@ Actualizacion 2026-05-15: patrón/paste, completion y estado de celdas/caret. - Cobertura nueva para RatingGroup: slider attrs, merge con Field, half hover/click, clearable y teclado LTR/RTL. + - Cobertura nueva para TagGroup: labels/root grid, roving por DOM + renderizado, selección/removal, link y remove button. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 220 archivos, 2209 tests OK. + - `npm run test` -> 221 archivos, 2212 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. @@ -170,6 +172,7 @@ Actualizacion 2026-05-15: - `splitter-provider.svelte.test.ts` -> 3 tests OK. - `pin-input-provider.svelte.test.ts` -> 3 tests OK. - `rating-group-provider.svelte.test.ts` -> 3 tests OK. + - `tag-group-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 5fc7c70e9..051f55c03 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -551,6 +551,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A merge con Field, patrón/paste, completion y estado de celdas/caret. - `rating-group/rating-group-provider.svelte.test.ts` — slider attrs, merge con Field, half hover/click, clearable y teclado LTR/RTL. +- `tag-group/tag-group-provider.svelte.test.ts` — labels/root grid, roving + por DOM renderizado, selección/removal, link y remove button. 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/tag-group/tag-group-provider.svelte.test.ts b/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts new file mode 100644 index 000000000..4e6d500d6 --- /dev/null +++ b/src/uix/soma/components/tag-group/tag-group-provider.svelte.test.ts @@ -0,0 +1,293 @@ +// @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 { + TagGroupItemProvider, + TagGroupLabelProvider, + TagGroupLinkProvider, + TagGroupProvider, + TagGroupRemoveButtonProvider +} from './tag-group-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, + prefs: { + getDir: vi.fn(() => 'ltr') + }, + langs: { + ts: vi.fn(() => 'Tags'), + t: vi.fn((_key: string, vars?: Record) => `Remove ${vars?.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(TagGroupProvider.ctx, 'set').mockImplementation((value) => value); + vi.spyOn(TagGroupItemProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function tagGroupOpts(root = document.createElement('div')) { + return { + id: state('tag-group-root'), + ref: state(root), + value: state(['beta']), + items: state(['alpha', 'beta', 'gamma']), + selectionMode: state<'none' | 'single' | 'multiple'>('multiple'), + disabled: state(false), + ariaLabel: state(undefined), + ariaLabelledby: state(undefined), + onValueChange: state<((value: string[]) => void) | undefined>(undefined), + onRemove: state<((value: string) => void) | undefined>(undefined) + }; +} + +function itemOpts(value: string, el: HTMLElement) { + return { + id: state(`tag-${value}`), + ref: state(el), + value: state(value), + disabled: state(false), + textValue: state(value.toUpperCase()) + }; +} + +function markRenderedItem(el: HTMLElement, value: string) { + el.setAttribute('data-tag-group-item', ''); + el.setAttribute('data-value', value); +} + +function keyEvent(key: string, currentTarget: HTMLElement) { + return { + key, + currentTarget, + preventDefault: vi.fn() + }; +} + +describe('TagGroupProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('projects root labels and resolves roving item props from selection', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + root.setAttribute('data-tag-group', ''); + const labelEl = document.createElement('div'); + const alphaEl = document.createElement('div'); + const betaEl = document.createElement('div'); + markRenderedItem(alphaEl, 'alpha'); + markRenderedItem(betaEl, 'beta'); + root.append(labelEl, alphaEl, betaEl); + document.body.appendChild(root); + const opts = tagGroupOpts(root); + const onValueChange = vi.fn(); + opts.onValueChange.current = onValueChange; + + const { result, cleanup } = withEffectRoot(() => { + const provider = TagGroupProvider.create(opts); + vi.spyOn(TagGroupProvider, 'require').mockReturnValue(provider); + const label = TagGroupLabelProvider.create({ + id: state('tag-group-label'), + ref: state(labelEl) + }); + const alpha = TagGroupItemProvider.create(itemOpts('alpha', alphaEl)); + const beta = TagGroupItemProvider.create(itemOpts('beta', betaEl)); + return { provider, label, alpha, beta }; + }); + + expect(result.provider.resolvedAriaLabel.current).toBeUndefined(); + expect(result.provider.resolvedAriaLabelledby).toBe('tag-group-label'); + expect(result.provider.isEmpty).toBe(false); + expect(result.provider.rovingTargetEl).toBe(betaEl); + expect(result.provider.snippetProps).toMatchObject({ + value: ['beta'], + isEmpty: false + }); + expect(result.alpha.props).toMatchObject({ + id: 'tag-alpha', + role: 'row', + tabindex: -1, + 'aria-selected': false, + 'data-state': 'unselected', + 'data-value': 'alpha', + 'data-text-value': 'ALPHA' + }); + expect(result.beta.props).toMatchObject({ + id: 'tag-beta', + tabindex: 0, + 'aria-selected': true, + 'data-state': 'selected', + 'data-highlighted': '' + }); + + result.provider.clear(); + expect(opts.value.current).toEqual([]); + expect(onValueChange).toHaveBeenLastCalledWith([]); + + await tick(); + expect(root.getAttribute('role')).toBe('grid'); + expect(root.getAttribute('aria-labelledby')).toBe('tag-group-label'); + expect(root.getAttribute('aria-multiselectable')).toBe('true'); + expect(root.getAttribute('data-selection-mode')).toBe('multiple'); + + cleanup(); + dom.dispose(); + }); + + it('selects, navigates and removes items from keyboard and click handlers', async () => { + const { dom } = installSomaHarness(); + const focusSpy = vi.spyOn(dom, 'focus'); + const root = document.createElement('div'); + root.setAttribute('data-tag-group', ''); + const alphaEl = document.createElement('div'); + const betaEl = document.createElement('div'); + const gammaEl = document.createElement('div'); + markRenderedItem(alphaEl, 'alpha'); + markRenderedItem(betaEl, 'beta'); + markRenderedItem(gammaEl, 'gamma'); + root.append(alphaEl, betaEl, gammaEl); + document.body.appendChild(root); + const opts = tagGroupOpts(root); + opts.value.current = []; + const onValueChange = vi.fn(); + const onRemove = vi.fn(); + opts.onValueChange.current = onValueChange; + opts.onRemove.current = onRemove; + + const { result, cleanup } = withEffectRoot(() => { + const provider = TagGroupProvider.create(opts); + vi.spyOn(TagGroupProvider, 'require').mockReturnValue(provider); + const alpha = TagGroupItemProvider.create(itemOpts('alpha', alphaEl)); + const beta = TagGroupItemProvider.create(itemOpts('beta', betaEl)); + const gamma = TagGroupItemProvider.create(itemOpts('gamma', gammaEl)); + return { provider, alpha, beta, gamma }; + }); + + const arrow = keyEvent('ArrowRight', alphaEl); + result.alpha.onkeydown(arrow as never); + expect(arrow.preventDefault).toHaveBeenCalledOnce(); + expect(focusSpy).toHaveBeenLastCalledWith(betaEl); + + const end = keyEvent('End', alphaEl); + result.alpha.onkeydown(end as never); + expect(end.preventDefault).toHaveBeenCalledOnce(); + expect(focusSpy).toHaveBeenLastCalledWith(gammaEl); + + const select = keyEvent('Enter', betaEl); + result.beta.onkeydown(select as never); + expect(select.preventDefault).toHaveBeenCalledOnce(); + expect(opts.value.current).toEqual(['beta']); + expect(onValueChange).toHaveBeenLastCalledWith(['beta']); + + result.gamma.onclick({ currentTarget: gammaEl } as never); + expect(opts.value.current).toEqual(['beta', 'gamma']); + expect(focusSpy).toHaveBeenLastCalledWith(gammaEl); + + const remove = keyEvent('Delete', betaEl); + result.beta.onkeydown(remove as never); + expect(remove.preventDefault).toHaveBeenCalledOnce(); + expect(opts.value.current).toEqual(['gamma']); + expect(onRemove).toHaveBeenCalledWith('beta'); + await Promise.resolve(); + expect(focusSpy).toHaveBeenLastCalledWith(gammaEl); + + opts.disabled.current = true; + result.gamma.onclick({ currentTarget: gammaEl } as never); + expect(opts.value.current).toEqual(['gamma']); + + cleanup(); + dom.dispose(); + }); + + it('exposes link and remove button props without breaking item roving semantics', () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + root.setAttribute('data-tag-group', ''); + const itemEl = document.createElement('div'); + const linkEl = document.createElement('a'); + const removeEl = document.createElement('button'); + markRenderedItem(itemEl, 'alpha'); + markRenderedItem(linkEl, 'docs'); + root.append(itemEl, linkEl, removeEl); + document.body.appendChild(root); + const opts = tagGroupOpts(root); + opts.value.current = ['docs']; + const onRemove = vi.fn(); + opts.onRemove.current = onRemove; + + const { result, cleanup } = withEffectRoot(() => { + const provider = TagGroupProvider.create(opts); + vi.spyOn(TagGroupProvider, 'require').mockReturnValue(provider); + const item = TagGroupItemProvider.create(itemOpts('alpha', itemEl)); + vi.spyOn(TagGroupItemProvider, 'require').mockReturnValue(item); + const link = TagGroupLinkProvider.create({ + id: state('tag-docs'), + ref: state(linkEl), + value: state('docs'), + href: state('/docs'), + disabled: state(false), + textValue: state('Docs') + }); + const removeButton = TagGroupRemoveButtonProvider.create({ + id: state('tag-alpha-remove'), + ref: state(removeEl), + ariaLabel: state(undefined) + }); + return { provider, item, link, removeButton }; + }); + + expect(result.link.props).toMatchObject({ + id: 'tag-docs', + 'data-tag-group-link': '', + 'data-tag-group-item': '', + role: 'row', + href: '/docs', + tabindex: 0, + 'data-state': 'selected', + 'data-value': 'docs', + 'data-text-value': 'Docs', + 'data-highlighted': '' + }); + expect(result.removeButton.props).toMatchObject({ + id: 'tag-alpha-remove', + type: 'button', + 'aria-label': 'Remove alpha', + tabindex: 0 + }); + + const click = { stopPropagation: vi.fn() }; + result.removeButton.onclick(click as never); + expect(click.stopPropagation).toHaveBeenCalledOnce(); + expect(onRemove).toHaveBeenCalledWith('alpha'); + + cleanup(); + dom.dispose(); + }); +});