diff --git a/continue.md b/continue.md index a9d228e3b..27806da35 100644 --- a/continue.md +++ b/continue.md @@ -105,9 +105,11 @@ Actualizacion 2026-05-15: clear, foco y OR-merge con Field. - Cobertura nueva para Pagination: rangos/ellipsis, clamp reactivo, slice, triggers prev/next, item selected y disabled global. + - Cobertura nueva para Breadcrumb: label/labelledby, separator, link current + con mirror al item y ellipsis decorativo/interactivo. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 213 archivos, 2187 tests OK. + - `npm run test` -> 214 archivos, 2190 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. @@ -149,6 +151,7 @@ Actualizacion 2026-05-15: - `field-provider.svelte.test.ts` -> 3 tests OK. - `search-field-provider.svelte.test.ts` -> 3 tests OK. - `pagination-provider.svelte.test.ts` -> 3 tests OK. + - `breadcrumb-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 b4d55778b..f3c9c0904 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -537,6 +537,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A input/submit/clear, foco y OR-merge con Field. - `pagination/pagination-provider.svelte.test.ts` — rangos/ellipsis, clamp reactivo, slice, triggers prev/next, item selected y disabled global. +- `breadcrumb/breadcrumb-provider.svelte.test.ts` — label/labelledby, + separator, link current con mirror al item y ellipsis decorativo/interactivo. 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/breadcrumb/breadcrumb-provider.svelte.test.ts b/src/uix/soma/components/breadcrumb/breadcrumb-provider.svelte.test.ts new file mode 100644 index 000000000..407fd9b39 --- /dev/null +++ b/src/uix/soma/components/breadcrumb/breadcrumb-provider.svelte.test.ts @@ -0,0 +1,204 @@ +// @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 { + BreadcrumbEllipsisProvider, + BreadcrumbItemProvider, + BreadcrumbLinkProvider, + BreadcrumbListProvider, + BreadcrumbProvider, + BreadcrumbSeparatorProvider +} from './breadcrumb-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('ellipsis') ? 'More' : 'Breadcrumb')) + }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(BreadcrumbProvider.ctx, 'set').mockImplementation((value) => value); + vi.spyOn(BreadcrumbItemProvider.ctx, 'set').mockImplementation((value) => value); + vi.spyOn(BreadcrumbItemProvider, 'get').mockReturnValue(undefined); + + return { dom }; +} + +function breadcrumbOpts(root = document.createElement('nav')) { + return { + id: state('breadcrumb-root'), + ref: state(root), + ariaLabel: state(undefined), + ariaLabelledby: state(undefined) + }; +} + +describe('BreadcrumbProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('projects provider label and exposes list/separator props', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('nav'); + const separatorEl = document.createElement('li'); + document.body.append(root, separatorEl); + const opts = breadcrumbOpts(root); + + const { result, cleanup } = withEffectRoot(() => { + const provider = BreadcrumbProvider.create(opts); + vi.spyOn(BreadcrumbProvider, 'require').mockReturnValue(provider); + const list = BreadcrumbListProvider.create({ + id: state('breadcrumb-list'), + ref: state(document.createElement('ol')) + }); + const separator = BreadcrumbSeparatorProvider.create({ + id: state('breadcrumb-separator'), + ref: state(separatorEl) + }); + return { provider, list, separator }; + }); + + expect(result.provider.props).toMatchObject({ + id: 'breadcrumb-root', + 'data-breadcrumb': '' + }); + expect(result.provider.resolvedAriaLabel.current).toBe('Breadcrumb'); + expect(result.list.props).toMatchObject({ + id: 'breadcrumb-list', + 'data-breadcrumb-list': '' + }); + expect(result.separator.props).toMatchObject({ + id: 'breadcrumb-separator', + 'data-breadcrumb-separator': '' + }); + + await tick(); + expect(root.getAttribute('aria-label')).toBe('Breadcrumb'); + expect(separatorEl.getAttribute('role')).toBe('presentation'); + expect(separatorEl.getAttribute('aria-hidden')).toBe('true'); + + opts.ariaLabelledby.current = 'breadcrumb-title'; + await tick(); + expect(root.hasAttribute('aria-label')).toBe(false); + expect(root.getAttribute('aria-labelledby')).toBe('breadcrumb-title'); + + cleanup(); + dom.dispose(); + }); + + it('mirrors current link state to the parent item and aria-current', async () => { + const { dom } = installSomaHarness(); + const itemEl = document.createElement('li'); + const linkEl = document.createElement('a'); + document.body.append(itemEl, linkEl); + const opts = breadcrumbOpts(); + const current = state(true); + + const { result, cleanup } = withEffectRoot(() => { + const provider = BreadcrumbProvider.create(opts); + vi.spyOn(BreadcrumbProvider, 'require').mockReturnValue(provider); + const item = BreadcrumbItemProvider.create({ + id: state('breadcrumb-item'), + ref: state(itemEl) + }); + vi.mocked(BreadcrumbItemProvider.get).mockReturnValue(item); + const link = BreadcrumbLinkProvider.create({ + id: state('breadcrumb-link'), + ref: state(linkEl), + current + }); + return { item, link }; + }); + + expect(result.link.renderSpan).toBe(true); + expect(result.link.ariaCurrent).toBe('page'); + + await tick(); + expect(result.item.isCurrent).toBe(true); + expect(itemEl.getAttribute('data-current')).toBe(''); + expect(linkEl.getAttribute('data-current')).toBe(''); + expect(linkEl.getAttribute('aria-current')).toBe('page'); + + current.current = 'step'; + await tick(); + expect(result.link.ariaCurrent).toBe('step'); + expect(linkEl.getAttribute('aria-current')).toBe('step'); + + current.current = false; + await tick(); + expect(result.link.renderSpan).toBe(false); + expect(result.item.isCurrent).toBe(false); + expect(itemEl.hasAttribute('data-current')).toBe(false); + expect(linkEl.hasAttribute('aria-current')).toBe(false); + + cleanup(); + dom.dispose(); + }); + + it('switches ellipsis between decorative and interactive semantics', async () => { + const { dom } = installSomaHarness(); + const ellipsisEl = document.createElement('span'); + document.body.appendChild(ellipsisEl); + const opts = breadcrumbOpts(); + const interactive = state(false); + + const { result, cleanup } = withEffectRoot(() => { + const provider = BreadcrumbProvider.create(opts); + vi.spyOn(BreadcrumbProvider, 'require').mockReturnValue(provider); + const ellipsis = BreadcrumbEllipsisProvider.create({ + id: state('breadcrumb-ellipsis'), + ref: state(ellipsisEl), + ariaLabel: state(undefined), + interactive, + popupType: state<'menu'>('menu') + }); + return { ellipsis }; + }); + + expect(result.ellipsis.props).toMatchObject({ + id: 'breadcrumb-ellipsis', + 'data-breadcrumb-ellipsis': '' + }); + await tick(); + expect(ellipsisEl.getAttribute('role')).toBe('presentation'); + expect(ellipsisEl.getAttribute('aria-label')).toBe('More'); + expect(ellipsisEl.hasAttribute('type')).toBe(false); + + interactive.current = true; + await tick(); + expect(ellipsisEl.getAttribute('data-interactive')).toBe(''); + expect(ellipsisEl.getAttribute('type')).toBe('button'); + expect(ellipsisEl.getAttribute('aria-haspopup')).toBe('menu'); + expect(ellipsisEl.hasAttribute('role')).toBe(false); + + cleanup(); + dom.dispose(); + }); +});