From 572779e90549b2ece58ca5c30e9bb95f048bf02e Mon Sep 17 00:00:00 2001 From: dev Date: Sat, 16 May 2026 01:50:47 +0200 Subject: [PATCH] Add toolbar provider coverage --- continue.md | 5 +- src/uix/soma/SOMA_ARCHITECTURE.md | 2 + .../toolbar/toolbar-provider.svelte.test.ts | 228 ++++++++++++++++++ 3 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 src/uix/soma/components/toolbar/toolbar-provider.svelte.test.ts diff --git a/continue.md b/continue.md index 27806da35..54b78cf36 100644 --- a/continue.md +++ b/continue.md @@ -107,9 +107,11 @@ Actualizacion 2026-05-15: 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. + - Cobertura nueva para Toolbar: roving focus, focusin, group single/multiple + y separator. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 214 archivos, 2190 tests OK. + - `npm run test` -> 215 archivos, 2193 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. @@ -152,6 +154,7 @@ Actualizacion 2026-05-15: - `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. + - `toolbar-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 f3c9c0904..492eb0c15 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -539,6 +539,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A 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. +- `toolbar/toolbar-provider.svelte.test.ts` — roving focus, focusin, group + single/multiple y separator. 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/toolbar/toolbar-provider.svelte.test.ts b/src/uix/soma/components/toolbar/toolbar-provider.svelte.test.ts new file mode 100644 index 000000000..4c6c81a6c --- /dev/null +++ b/src/uix/soma/components/toolbar/toolbar-provider.svelte.test.ts @@ -0,0 +1,228 @@ +// @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 { + ToolbarButtonProvider, + ToolbarGroupItemProvider, + ToolbarGroupProvider, + ToolbarLinkProvider, + ToolbarProvider, + ToolbarSeparatorProvider +} from './toolbar-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, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => key, + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(ToolbarProvider.ctx, 'set').mockImplementation((value) => value); + vi.spyOn(ToolbarGroupProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom }; +} + +function toolbarOpts(root: HTMLElement) { + return { + id: state('toolbar-root'), + ref: state(root), + orientation: state<'horizontal' | 'vertical'>('horizontal'), + loop: state(true), + dir: state<'ltr' | 'rtl'>('ltr') + }; +} + +function keyEvent(key: string) { + return { + key, + preventDefault: vi.fn() + }; +} + +describe('ToolbarProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('roves tabindex and focus across buttons and links', async () => { + const { dom } = installSomaHarness(); + const focusSpy = vi.spyOn(dom, 'focus'); + const root = document.createElement('div'); + root.setAttribute('data-toolbar', ''); + const firstEl = document.createElement('button'); + firstEl.setAttribute('data-toolbar-button', ''); + const disabledEl = document.createElement('button'); + disabledEl.setAttribute('data-toolbar-button', ''); + disabledEl.setAttribute('disabled', ''); + const linkEl = document.createElement('a'); + linkEl.setAttribute('data-toolbar-link', ''); + root.append(firstEl, disabledEl, linkEl); + document.body.appendChild(root); + const opts = toolbarOpts(root); + + const { result, cleanup } = withEffectRoot(() => { + const provider = ToolbarProvider.create(opts); + vi.spyOn(ToolbarProvider, 'require').mockReturnValue(provider); + const first = ToolbarButtonProvider.create({ + id: state('toolbar-first'), + ref: state(firstEl), + disabled: state(false) + }); + const disabled = ToolbarButtonProvider.create({ + id: state('toolbar-disabled'), + ref: state(disabledEl), + disabled: state(true) + }); + const link = ToolbarLinkProvider.create({ + id: state('toolbar-link'), + ref: state(linkEl) + }); + return { provider, first, disabled, link }; + }); + + expect(result.provider.props).toMatchObject({ + id: 'toolbar-root', + 'data-toolbar': '', + dir: 'ltr' + }); + expect(result.first.props.tabindex).toBe(0); + expect(result.disabled.props.disabled).toBe(true); + expect(result.link.props.tabindex).toBe(-1); + + firstEl.focus(); + const next = keyEvent('ArrowRight'); + result.provider.onkeydown(next as never); + expect(next.preventDefault).toHaveBeenCalledOnce(); + expect(focusSpy).toHaveBeenCalledWith(linkEl); + expect(result.provider.lastFocusedElement).toBe(linkEl); + expect(result.first.props.tabindex).toBe(-1); + expect(result.link.props.tabindex).toBe(0); + + const home = keyEvent('Home'); + linkEl.focus(); + result.provider.onkeydown(home as never); + expect(home.preventDefault).toHaveBeenCalledOnce(); + expect(focusSpy).toHaveBeenLastCalledWith(firstEl); + + await tick(); + expect(root.getAttribute('role')).toBe('toolbar'); + expect(root.getAttribute('data-orientation')).toBe('horizontal'); + expect(disabledEl.getAttribute('data-disabled')).toBe(''); + + cleanup(); + dom.dispose(); + }); + + it('updates last focused element from focusin', () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + const buttonEl = document.createElement('button'); + document.body.append(root, buttonEl); + const opts = toolbarOpts(root); + + const { result: provider, cleanup } = withEffectRoot(() => ToolbarProvider.create(opts)); + + provider.onfocusin({ target: buttonEl } as unknown as FocusEvent); + expect(provider.lastFocusedElement).toBe(buttonEl); + + cleanup(); + dom.dispose(); + }); + + it('toggles group items and projects separator orientation', async () => { + const { dom } = installSomaHarness(); + const root = document.createElement('div'); + root.setAttribute('data-toolbar', ''); + const groupEl = document.createElement('div'); + const boldEl = document.createElement('button'); + boldEl.setAttribute('data-toolbar-group-item', ''); + const italicEl = document.createElement('button'); + italicEl.setAttribute('data-toolbar-group-item', ''); + const separatorEl = document.createElement('div'); + root.append(groupEl, boldEl, italicEl, separatorEl); + document.body.appendChild(root); + const opts = toolbarOpts(root); + const value = state([]); + const groupType = state<'single' | 'multiple'>('single'); + const groupDisabled = state(false); + + const { result, cleanup } = withEffectRoot(() => { + const provider = ToolbarProvider.create(opts); + vi.spyOn(ToolbarProvider, 'require').mockReturnValue(provider); + const group = ToolbarGroupProvider.create({ + id: state('toolbar-group'), + ref: state(groupEl), + value, + type: groupType, + disabled: groupDisabled + }); + vi.spyOn(ToolbarGroupProvider, 'require').mockReturnValue(group); + const bold = ToolbarGroupItemProvider.create({ + id: state('toolbar-bold'), + ref: state(boldEl), + value: state('bold'), + disabled: state(false) + }); + const italic = ToolbarGroupItemProvider.create({ + id: state('toolbar-italic'), + ref: state(italicEl), + value: state('italic'), + disabled: state(false) + }); + const separator = ToolbarSeparatorProvider.create({ + id: state('toolbar-separator'), + ref: state(separatorEl) + }); + return { group, bold, italic, separator }; + }); + + result.bold.onclick({ currentTarget: boldEl } as never); + expect(value.current).toEqual(['bold']); + result.italic.onclick({ currentTarget: italicEl } as never); + expect(value.current).toEqual(['italic']); + + groupType.current = 'multiple'; + result.bold.onclick({ currentTarget: boldEl } as never); + expect(value.current).toEqual(['italic', 'bold']); + result.italic.onclick({ currentTarget: italicEl } as never); + expect(value.current).toEqual(['bold']); + + await tick(); + expect(boldEl.getAttribute('data-state')).toBe('on'); + expect(italicEl.getAttribute('data-state')).toBe('off'); + expect(separatorEl.getAttribute('role')).toBe('separator'); + expect(separatorEl.getAttribute('aria-orientation')).toBe('vertical'); + + groupDisabled.current = true; + result.bold.onclick({ currentTarget: boldEl } as never); + expect(value.current).toEqual(['bold']); + expect(result.bold.props.disabled).toBe(true); + + cleanup(); + dom.dispose(); + }); +});