diff --git a/continue.md b/continue.md index 1618c4998..01d351e3b 100644 --- a/continue.md +++ b/continue.md @@ -141,9 +141,11 @@ Actualizacion 2026-05-15: `$libs/datagrid`, selección de fila y disclosure `RowDetail`. - Cobertura nueva para Form: submit inválido con foco via `ActiveDom`, attrs runtime y partes Submit/Reset/ErrorSummary sobre `$libs/forms`. + - Cobertura nueva para ToastProvider: viewport hotkey via `ActiveDom`, + runtime por item, ARIA/parts, auto-dismiss y close action. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 231 archivos, 2238 tests OK. + - `npm run test` -> 232 archivos, 2240 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. @@ -203,6 +205,7 @@ Actualizacion 2026-05-15: - `link-preview-provider.svelte.test.ts` -> 2 tests OK. - `table-provider.svelte.test.ts` -> 2 tests OK. - `form-provider.svelte.test.ts` -> 2 tests OK. + - `toast-provider.svelte.test.ts` -> 2 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 36dd3edc2..231789b76 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -573,6 +573,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A desde `$libs/datagrid`, selección de fila y disclosure `RowDetail`. - `form/form-provider.svelte.test.ts` — submit inválido con foco via `ActiveDom`, attrs runtime y partes Submit/Reset/ErrorSummary. +- `toast/toast-provider.svelte.test.ts` — viewport hotkey via `ActiveDom`, + runtime por item, ARIA/parts, auto-dismiss y close action. 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/toast/toast-provider.svelte.test.ts b/src/uix/soma/components/toast/toast-provider.svelte.test.ts new file mode 100644 index 000000000..1a761926f --- /dev/null +++ b/src/uix/soma/components/toast/toast-provider.svelte.test.ts @@ -0,0 +1,248 @@ +// @vitest-environment jsdom + +import { tick } from 'svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createActiveDom } from '$adom'; +import { state, type 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 { + ToastActionProvider, + ToastCloseProvider, + ToastDescriptionProvider, + ToastItemProvider, + ToastMainProvider, + ToastProvider, + ToastStatusProvider, + ToastTitleProvider, + ToastViewportProvider +} from './toast-provider.svelte'; +import { Toaster, type ToastData } from './toaster.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 tasks = new Map void>(); + const handles = new Map< + string, + { cancel: ReturnType; reschedule: ReturnType } + >(); + const schedule = vi.fn((key: string, _delay: number, task: () => void) => { + tasks.set(key, task); + const handle = { + cancel: vi.fn(() => tasks.delete(key)), + reschedule: vi.fn() + }; + handles.set(key, handle); + return handle; + }); + const soma = { + dom, + langs: { + ts: vi.fn((key: string) => (key.includes('close') ? 'Close' : key)) + }, + uix: { + timers: { schedule } + }, + runtime: (morfo: Morfo, sources: Omit) => + createSomaRuntime(morfo, { + dom, + translate: (key) => soma.langs.ts(key), + ...sources + }) + } as unknown as Soma; + + vi.spyOn(Soma, 'require').mockReturnValue(soma); + vi.spyOn(ToastProvider.ctx, 'set').mockImplementation((value) => value); + vi.spyOn(ToastItemProvider.ctx, 'set').mockImplementation((value) => value); + + return { dom, schedule, tasks, handles }; +} + +function toastProviderOpts(toaster: Toaster) { + return { + id: state('toast-root'), + ref: state(null), + toaster: state(toaster), + label: state('Notifications') + }; +} + +function itemOpts(toast: State, itemEl: HTMLElement) { + return { + id: state('toast-item'), + ref: state(itemEl), + toast + }; +} + +function keyboardEvent(key: string, code: string, init: KeyboardEventInit = {}) { + return new KeyboardEvent('keydown', { + key, + code, + bubbles: true, + cancelable: true, + ...init + }); +} + +describe('ToastProvider parts', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('projects viewport attrs and focuses it through the toaster hotkey', async () => { + const { dom } = installSomaHarness(); + const focusSpy = vi.spyOn(dom, 'focus'); + const toaster = new Toaster({ duration: 0, hotkey: ['AltLeft', 'F8'] }); + const viewportEl = document.createElement('div'); + document.body.appendChild(viewportEl); + + const { result, cleanup } = withEffectRoot(() => { + const provider = ToastProvider.create(toastProviderOpts(toaster)); + vi.spyOn(ToastProvider, 'require').mockReturnValue(provider); + const viewport = ToastViewportProvider.create({ + id: state('toast-viewport'), + ref: state(viewportEl) + }); + return { provider, viewport }; + }); + + expect(result.provider.toaster).toBe(toaster); + expect(result.viewport.props).toMatchObject({ + id: 'toast-viewport', + 'data-toast-viewport': '', + tabindex: -1 + }); + await tick(); + expect(viewportEl.getAttribute('role')).toBe('region'); + expect(viewportEl.getAttribute('aria-label')).toBe('Notifications'); + expect(viewportEl.getAttribute('aria-live')).toBe('polite'); + + document.dispatchEvent(keyboardEvent('Alt', 'AltLeft', { altKey: true })); + const f8 = keyboardEvent('F8', 'F8', { altKey: true }); + document.dispatchEvent(f8); + expect(f8.defaultPrevented).toBe(true); + expect(focusSpy).toHaveBeenCalledWith(viewportEl); + + cleanup(); + dom.dispose(); + }); + + it('uses a per-item runtime for ARIA, timers and dismiss actions', async () => { + const harness = installSomaHarness(); + const toaster = new Toaster({ duration: 100, swipeDirection: 'right', swipeThreshold: 50 }); + const id = toaster.create({ + title: 'Saved', + description: 'Record persisted', + intent: 'risk', + loading: true, + duration: 100 + }); + const toastState = state(toaster.toasts[0]!); + const itemEl = document.createElement('div'); + const titleEl = document.createElement('div'); + const descriptionEl = document.createElement('div'); + const actionEl = document.createElement('button'); + const closeEl = document.createElement('button'); + const statusEl = document.createElement('span'); + const mainEl = document.createElement('div'); + document.body.append(itemEl, titleEl, descriptionEl, actionEl, closeEl, statusEl, mainEl); + + const { result, cleanup } = withEffectRoot(() => { + const provider = ToastProvider.create(toastProviderOpts(toaster)); + vi.spyOn(ToastProvider, 'require').mockReturnValue(provider); + const item = ToastItemProvider.create(itemOpts(toastState, itemEl)); + vi.spyOn(ToastItemProvider, 'require').mockReturnValue(item); + const title = ToastTitleProvider.create({ + id: state('toast-title'), + ref: state(titleEl) + }); + const description = ToastDescriptionProvider.create({ + id: state('toast-description'), + ref: state(descriptionEl) + }); + const action = ToastActionProvider.create({ + id: state('toast-action'), + ref: state(actionEl), + altText: state('Undo save') + }); + const close = ToastCloseProvider.create({ + id: state('toast-close'), + ref: state(closeEl) + }); + const status = ToastStatusProvider.create({ + id: state('toast-status'), + ref: state(statusEl) + }); + const main = ToastMainProvider.create({ + id: state('toast-main'), + ref: state(mainEl) + }); + return { provider, item, title, description, action, close, status, main }; + }); + + expect(harness.schedule).toHaveBeenCalledWith( + 'soma:toast:auto-dismiss:toast-item', + 100, + expect.any(Function), + expect.objectContaining({ + replace: true, + meta: { component: 'toast', action: 'auto-dismiss' } + }) + ); + expect(result.item.props).toMatchObject({ + id: 'toast-item', + 'data-toast-item': '', + tabindex: 0 + }); + expect(result.title.props).toMatchObject({ id: 'toast-title', 'data-toast-title': '' }); + expect(result.description.props).toMatchObject({ + id: 'toast-description', + 'data-toast-description': '' + }); + expect(result.status.props).toMatchObject({ id: 'toast-status', 'data-toast-status': '' }); + expect(result.main.props).toMatchObject({ id: 'toast-main', 'data-toast-main': '' }); + expect(result.close.props.onclick).toBe(result.close.onclick); + + await tick(); + expect(itemEl.getAttribute('role')).toBe('alert'); + expect(itemEl.getAttribute('aria-live')).toBe('assertive'); + expect(itemEl.getAttribute('aria-atomic')).toBe('true'); + expect(itemEl.getAttribute('aria-labelledby')).toBe('toast-title'); + expect(itemEl.getAttribute('aria-describedby')).toBe('toast-description'); + expect(itemEl.getAttribute('data-intent')).toBe('risk'); + expect(itemEl.getAttribute('data-loading')).toBe(''); + expect(actionEl.getAttribute('type')).toBe('button'); + expect(actionEl.getAttribute('aria-label')).toBe('Undo save'); + expect(closeEl.getAttribute('type')).toBe('button'); + expect(closeEl.getAttribute('aria-label')).toBe('Close'); + expect(statusEl.getAttribute('aria-hidden')).toBe('true'); + + result.item.onpointerenter(); + expect(harness.handles.get('soma:toast:auto-dismiss:toast-item')?.cancel).toHaveBeenCalled(); + result.item.onpointerleave(); + expect(harness.schedule).toHaveBeenCalledTimes(2); + + result.close.onclick({ currentTarget: closeEl } as never); + await tick(); + expect(toaster.toasts.find((toast) => toast.id === id)?.dismissing).toBe(true); + toastState.current = toaster.toasts[0]!; + await tick(); + expect(itemEl.getAttribute('data-state')).toBe('closed'); + + cleanup(); + harness.dom.dispose(); + }); +});