diff --git a/continue.md b/continue.md index 6ee7d4a6c..90c9621a0 100644 --- a/continue.md +++ b/continue.md @@ -82,9 +82,11 @@ Actualizacion 2026-05-15: navegación con auto-select, hidden input y guardas readonly/disabled. - Cobertura nueva para Switch: commit-toggle runtime, keyboard, hidden input, integración con FieldProvider y thumb. + - Cobertura nueva para Toggle: commit-toggle runtime, attrs DOM via + `ActiveDom`, integración con FieldProvider y guardas readonly/disabled. - Validado por fase: - `npm run check` -> 0 errores, 0 warnings. - - `npm run test` -> 201 archivos, 2150 tests OK. + - `npm run test` -> 202 archivos, 2154 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. @@ -114,6 +116,7 @@ Actualizacion 2026-05-15: - `checkbox-provider.svelte.test.ts` -> 4 tests OK. - `radio-group-provider.svelte.test.ts` -> 4 tests OK. - `switch-provider.svelte.test.ts` -> 5 tests OK. + - `toggle-provider.svelte.test.ts` -> 4 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 5336e346a..fde30503f 100644 --- a/src/uix/soma/SOMA_ARCHITECTURE.md +++ b/src/uix/soma/SOMA_ARCHITECTURE.md @@ -514,13 +514,15 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A selección síncrona, navegación con auto-select, hidden input y guardas. - `switch/switch-provider.svelte.test.ts` — `commit-toggle` runtime, keyboard, hidden input, integración con `FieldProvider` y thumb. +- `toggle/toggle-provider.svelte.test.ts` — `commit-toggle` runtime, attrs DOM + via `ActiveDom`, integración con `FieldProvider` y guardas. Pendiente: seguir ampliando cobertura por familias de alto riesgo fuera del bloque fecha/hora, virtualización, inputs numericos, file upload, color y navegacion superior/treegrid/tree-view/dropdown/context-menu/menubar/listbox/ -accordion/checkbox/radio-group/switch. `table-core`, `form-core` y el scorer -de Command ya no viven dentro de Soma: se consumen desde `$libs/datagrid`, -`$libs/forms` y `$libs/strings`. +accordion/checkbox/radio-group/switch/toggle. `table-core`, `form-core` y el +scorer de Command ya no viven dentro de Soma: se consumen desde +`$libs/datagrid`, `$libs/forms` y `$libs/strings`. ### Convencion diff --git a/src/uix/soma/components/toggle/toggle-provider.svelte.test.ts b/src/uix/soma/components/toggle/toggle-provider.svelte.test.ts new file mode 100644 index 000000000..39b5380cf --- /dev/null +++ b/src/uix/soma/components/toggle/toggle-provider.svelte.test.ts @@ -0,0 +1,170 @@ +// @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 { FieldProvider } from '../field/field-provider.svelte'; + +import { ToggleProvider } from './toggle-provider.svelte'; + +function withEffectRoot(fn: () => T): { result: T; cleanup: () => void } { + let result!: T; + const cleanup = $effect.root(() => { + result = fn(); + }); + return { result, cleanup }; +} + +async function flushRuntimeTrigger() { + await Promise.resolve(); + await tick(); +} + +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(FieldProvider, 'get').mockReturnValue(undefined); + + return { dom }; +} + +function toggleOpts(id = 'toggle-root') { + return { + id: state(id), + ref: state(document.createElement('button')), + pressed: state(false), + disabled: state(false), + readonly: state(false), + required: state(false), + invalid: state(false), + intent: state<'neutral' | 'affirm' | 'risk' | 'threat'>('neutral'), + color: state<'primary' | 'secondary'>('primary'), + name: state(''), + value: state('on'), + 'aria-label': state(''), + onPressedChange: state<(pressed: boolean) => void>(() => undefined) + }; +} + +describe('ToggleProvider', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('toggles pressed state through the runtime event', async () => { + const { dom } = installSomaHarness(); + const opts = toggleOpts(); + const onPressedChange = vi.fn(); + opts.onPressedChange.current = onPressedChange; + + const { result: provider, cleanup } = withEffectRoot(() => ToggleProvider.create(opts)); + + expect(provider.props).toMatchObject({ + id: 'toggle-root', + 'data-toggle': '', + 'data-archetype': 'trigger', + disabled: undefined + }); + expect(provider.snippetProps).toEqual({ pressed: false }); + + provider.onclick({} as never); + await flushRuntimeTrigger(); + expect(opts.pressed.current).toBe(true); + expect(onPressedChange).toHaveBeenCalledWith(true); + expect(provider.snippetProps).toEqual({ pressed: true }); + + provider.onclick({} as never); + await flushRuntimeTrigger(); + expect(opts.pressed.current).toBe(false); + expect(onPressedChange).toHaveBeenLastCalledWith(false); + + cleanup(); + dom.dispose(); + }); + + it('syncs morfo attrs through ActiveDom', async () => { + const { dom } = installSomaHarness(); + const el = document.createElement('button'); + const opts = toggleOpts(); + opts.ref.current = el; + opts['aria-label'].current = 'Pin'; + + const { cleanup } = withEffectRoot(() => ToggleProvider.create(opts)); + await tick(); + + expect(el.getAttribute('data-state')).toBe('off'); + expect(el.getAttribute('data-color')).toBe('primary'); + expect(el.getAttribute('aria-pressed')).toBe('false'); + expect(el.getAttribute('aria-label')).toBe('Pin'); + + opts.intent.current = 'threat'; + opts.pressed.current = true; + await tick(); + expect(el.getAttribute('data-state')).toBe('on'); + expect(el.getAttribute('data-color')).toBe('threat'); + expect(el.getAttribute('aria-pressed')).toBe('true'); + + cleanup(); + dom.dispose(); + }); + + it('merges FieldProvider flags and registers itself as field input', async () => { + const { dom } = installSomaHarness(); + const field = { + inputId: state(''), + isDisabled: true, + isReadonly: false, + isRequired: true, + isInvalid: true + }; + vi.spyOn(FieldProvider, 'get').mockReturnValue(field as FieldProvider); + const opts = toggleOpts('field-toggle'); + + const { result: provider, cleanup } = withEffectRoot(() => ToggleProvider.create(opts)); + + expect(field.inputId.current).toBe('field-toggle'); + expect(provider.isDisabled).toBe(true); + expect(provider.isRequired).toBe(true); + expect(provider.isInvalid).toBe(true); + expect(provider.props.disabled).toBe(true); + + provider.onclick({} as never); + await flushRuntimeTrigger(); + expect(opts.pressed.current).toBe(false); + + cleanup(); + dom.dispose(); + }); + + it('blocks readonly toggles while keeping the button enabled', async () => { + const { dom } = installSomaHarness(); + const opts = toggleOpts(); + opts.readonly.current = true; + + const { result: provider, cleanup } = withEffectRoot(() => ToggleProvider.create(opts)); + + provider.onclick({} as never); + await flushRuntimeTrigger(); + expect(opts.pressed.current).toBe(false); + expect(provider.props.disabled).toBeUndefined(); + + cleanup(); + dom.dispose(); + }); +});