Add tabs provider coverage

active-uix
dev 5 months ago
parent 15dc542117
commit 9975ba8631

@ -86,9 +86,12 @@ Actualizacion 2026-05-15:
`ActiveDom`, integración con FieldProvider y guardas readonly/disabled.
- Cobertura nueva para ToggleGroup: modos single/multiple, roving tabindex y
navegación de items saltando disabled.
- Cobertura nueva para Tabs: registro de trigger/content, activacion
automatic/manual, navegacion roving, fallback tab stop y partes
list/content/indicator.
- Validado por fase:
- `npm run check` -> 0 errores, 0 warnings.
- `npm run test` -> 203 archivos, 2158 tests OK.
- `npm run test` -> 204 archivos, 2162 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.
@ -120,6 +123,7 @@ Actualizacion 2026-05-15:
- `switch-provider.svelte.test.ts` -> 5 tests OK.
- `toggle-provider.svelte.test.ts` -> 4 tests OK.
- `toggle-group-provider.svelte.test.ts` -> 4 tests OK.
- `tabs-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.

@ -518,11 +518,11 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A
via `ActiveDom`, integración con `FieldProvider` y guardas.
- `toggle-group/toggle-group-provider.svelte.test.ts` — modos single/multiple,
roving tabindex y navegación de items saltando disabled.
- `tabs/tabs-provider.svelte.test.ts` — registros trigger/content, activacion
automatic/manual, navegacion roving, fallback tab stop y partes auxiliares.
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/toggle/toggle-group. `table-core`,
Pendiente: seguir ampliando cobertura por el resto del catalogo Soma, ya por
componentes de riesgo medio y familias menos centrales. `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`.

@ -0,0 +1,324 @@
// @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 {
TabsContentProvider,
TabsIndicatorProvider,
TabsListProvider,
TabsProvider,
TabsTriggerProvider
} from './tabs-provider.svelte';
function withEffectRoot<T>(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<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
createSomaRuntime(morfo, {
dom,
translate: (key) => key,
...sources
})
} as unknown as Soma;
vi.spyOn(Soma, 'require').mockReturnValue(soma);
vi.spyOn(TabsProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function createRoot() {
const root = document.createElement('div');
root.setAttribute('data-tabs', '');
document.body.appendChild(root);
return root;
}
function createTriggerEl(value: string, disabled = false) {
const trigger = document.createElement('button');
trigger.setAttribute('data-tabs-trigger', '');
trigger.setAttribute('data-value', value);
if (disabled) trigger.setAttribute('disabled', '');
return trigger;
}
function createContentEl(value: string) {
const content = document.createElement('div');
content.setAttribute('data-tabs-content', '');
content.setAttribute('data-value', value);
return content;
}
function tabsOpts(root = createRoot()) {
return {
id: state('tabs-root'),
ref: state<HTMLElement | null>(root),
value: state('account'),
orientation: state<'horizontal' | 'vertical'>('horizontal'),
activationMode: state<'automatic' | 'manual'>('automatic'),
disabled: state(false),
loop: state(true),
dir: state<'ltr' | 'rtl'>('ltr'),
onFocusChange: state<(value: string) => void>(() => undefined)
};
}
function triggerOpts(id: string, value: string, el: HTMLElement, disabled = false) {
return {
id: state(id),
ref: state<HTMLElement | null>(el),
value: state(value),
disabled: state(disabled)
};
}
function contentOpts(id: string, value: string, el: HTMLElement) {
return {
id: state(id),
ref: state<HTMLElement | null>(el),
value: state(value)
};
}
function keyEvent(key: string, currentTarget: HTMLElement) {
return {
key,
currentTarget,
preventDefault: vi.fn()
};
}
function focusEvent(currentTarget: HTMLElement): FocusEvent {
return { currentTarget } as unknown as FocusEvent;
}
describe('TabsProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('registers parts and exposes active trigger/content props', () => {
const { dom } = installSomaHarness();
const root = createRoot();
const accountTriggerEl = createTriggerEl('account');
const profileTriggerEl = createTriggerEl('profile');
const accountContentEl = createContentEl('account');
const profileContentEl = createContentEl('profile');
root.append(accountTriggerEl, profileTriggerEl, accountContentEl, profileContentEl);
const opts = tabsOpts(root);
const { result, cleanup } = withEffectRoot(() => {
const provider = TabsProvider.create(opts);
vi.spyOn(TabsProvider, 'require').mockReturnValue(provider);
const list = TabsListProvider.create({
id: state('tabs-list'),
ref: state<HTMLElement | null>(document.createElement('div'))
});
const accountTrigger = TabsTriggerProvider.create(
triggerOpts('tabs-trigger-account', 'account', accountTriggerEl)
);
const profileTrigger = TabsTriggerProvider.create(
triggerOpts('tabs-trigger-profile', 'profile', profileTriggerEl)
);
const accountContent = TabsContentProvider.create(
contentOpts('tabs-content-account', 'account', accountContentEl)
);
const profileContent = TabsContentProvider.create(
contentOpts('tabs-content-profile', 'profile', profileContentEl)
);
const indicator = TabsIndicatorProvider.create({
id: state('tabs-indicator'),
ref: state<HTMLElement | null>(document.createElement('div'))
});
return {
provider,
list,
accountTrigger,
profileTrigger,
accountContent,
profileContent,
indicator
};
});
expect(result.provider.props).toMatchObject({
id: 'tabs-root',
'data-tabs': '',
dir: 'ltr'
});
expect(result.list.props).toMatchObject({
id: 'tabs-list',
'data-tabs-list': ''
});
expect(result.indicator.props).toMatchObject({
id: 'tabs-indicator',
'data-tabs-indicator': ''
});
expect(result.provider.triggerIds).toMatchObject({
account: 'tabs-trigger-account',
profile: 'tabs-trigger-profile'
});
expect(result.provider.contentIds).toMatchObject({
account: 'tabs-content-account',
profile: 'tabs-content-profile'
});
expect(result.accountTrigger.props).toMatchObject({
tabindex: 0,
'data-value': 'account',
disabled: undefined
});
expect(result.profileTrigger.props.tabindex).toBe(-1);
expect(result.accountContent.props).toMatchObject({
tabindex: 0,
hidden: undefined
});
expect(result.profileContent.props).toMatchObject({
tabindex: -1,
hidden: true
});
cleanup();
dom.dispose();
});
it('supports automatic focus activation and manual keyboard activation', () => {
const { dom } = installSomaHarness();
const root = createRoot();
const accountTriggerEl = createTriggerEl('account');
const profileTriggerEl = createTriggerEl('profile');
root.append(accountTriggerEl, profileTriggerEl);
const opts = tabsOpts(root);
const onFocusChange = vi.fn();
opts.onFocusChange.current = onFocusChange;
const { result, cleanup } = withEffectRoot(() => {
const provider = TabsProvider.create(opts);
vi.spyOn(TabsProvider, 'require').mockReturnValue(provider);
const accountTrigger = TabsTriggerProvider.create(
triggerOpts('tabs-trigger-account', 'account', accountTriggerEl)
);
const profileTrigger = TabsTriggerProvider.create(
triggerOpts('tabs-trigger-profile', 'profile', profileTriggerEl)
);
return { accountTrigger, profileTrigger };
});
result.profileTrigger.onfocus(focusEvent(profileTriggerEl));
expect(onFocusChange).toHaveBeenCalledWith('profile');
expect(opts.value.current).toBe('profile');
opts.value.current = 'account';
opts.activationMode.current = 'manual';
result.profileTrigger.onfocus(focusEvent(profileTriggerEl));
expect(onFocusChange).toHaveBeenLastCalledWith('profile');
expect(opts.value.current).toBe('account');
const enter = keyEvent('Enter', profileTriggerEl);
result.profileTrigger.onkeydown(enter as never);
expect(enter.preventDefault).toHaveBeenCalledOnce();
expect(opts.value.current).toBe('profile');
result.accountTrigger.onclick({ currentTarget: accountTriggerEl } as never);
expect(opts.value.current).toBe('account');
cleanup();
dom.dispose();
});
it('moves focus with roving keyboard navigation and skips disabled triggers', () => {
const { dom } = installSomaHarness();
const focusSpy = vi.spyOn(dom, 'focus');
const root = createRoot();
const accountTriggerEl = createTriggerEl('account');
const disabledTriggerEl = createTriggerEl('disabled', true);
const profileTriggerEl = createTriggerEl('profile');
root.append(accountTriggerEl, disabledTriggerEl, profileTriggerEl);
const opts = tabsOpts(root);
opts.activationMode.current = 'manual';
const { result, cleanup } = withEffectRoot(() => {
const provider = TabsProvider.create(opts);
vi.spyOn(TabsProvider, 'require').mockReturnValue(provider);
const accountTrigger = TabsTriggerProvider.create(
triggerOpts('tabs-trigger-account', 'account', accountTriggerEl)
);
const disabledTrigger = TabsTriggerProvider.create(
triggerOpts('tabs-trigger-disabled', 'disabled', disabledTriggerEl, true)
);
const profileTrigger = TabsTriggerProvider.create(
triggerOpts('tabs-trigger-profile', 'profile', profileTriggerEl)
);
return { accountTrigger, disabledTrigger, profileTrigger };
});
expect(result.disabledTrigger.props.disabled).toBe(true);
const next = keyEvent('ArrowRight', accountTriggerEl);
result.accountTrigger.onkeydown(next as never);
expect(next.preventDefault).toHaveBeenCalledOnce();
expect(focusSpy).toHaveBeenCalledWith(profileTriggerEl);
const home = keyEvent('Home', profileTriggerEl);
result.profileTrigger.onkeydown(home as never);
expect(home.preventDefault).toHaveBeenCalledOnce();
expect(focusSpy).toHaveBeenLastCalledWith(accountTriggerEl);
const end = keyEvent('End', accountTriggerEl);
result.accountTrigger.onkeydown(end as never);
expect(end.preventDefault).toHaveBeenCalledOnce();
expect(focusSpy).toHaveBeenLastCalledWith(profileTriggerEl);
cleanup();
dom.dispose();
});
it('keeps the first enabled trigger tabbable when the active value is missing', async () => {
const { dom } = installSomaHarness();
const root = createRoot();
const accountTriggerEl = createTriggerEl('account');
const profileTriggerEl = createTriggerEl('profile');
root.append(accountTriggerEl, profileTriggerEl);
const opts = tabsOpts(root);
opts.value.current = 'missing';
const { result, cleanup } = withEffectRoot(() => {
const provider = TabsProvider.create(opts);
vi.spyOn(TabsProvider, 'require').mockReturnValue(provider);
const accountTrigger = TabsTriggerProvider.create(
triggerOpts('tabs-trigger-account', 'account', accountTriggerEl)
);
const profileTrigger = TabsTriggerProvider.create(
triggerOpts('tabs-trigger-profile', 'profile', profileTriggerEl)
);
return { accountTrigger, profileTrigger };
});
expect(result.accountTrigger.props.tabindex).toBe(0);
expect(result.profileTrigger.props.tabindex).toBe(-1);
opts.value.current = 'profile';
await tick();
expect(result.accountTrigger.props.tabindex).toBe(-1);
expect(result.profileTrigger.props.tabindex).toBe(0);
cleanup();
dom.dispose();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.