Add menubar provider coverage

active-uix
dev 5 months ago
parent 5c434a0cf2
commit ad5a9627c7

@ -68,9 +68,11 @@ Actualizacion 2026-05-15:
- Cobertura nueva para ContextMenu: apertura por `contextmenu` con anchor
virtual, scoping de items, item select, checkbox/radio, submenu delayed/open
y group/separator.
- Cobertura nueva para Menubar: coordinación de menús hermanos, hover-follow,
navegación horizontal entre triggers y cambio de menú desde content.
- Validado por fase:
- `npm run check` -> 0 errores, 0 warnings.
- `npm run test` -> 194 archivos, 2121 tests OK.
- `npm run test` -> 195 archivos, 2125 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.
@ -93,6 +95,7 @@ Actualizacion 2026-05-15:
- `tree-grid-provider.svelte.test.ts` -> 4 tests OK.
- `dropdown-menu-provider.svelte.test.ts` -> 6 tests OK.
- `context-menu-provider.svelte.test.ts` -> 6 tests OK.
- `menubar-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.

@ -500,12 +500,14 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A
- `context-menu/context-menu-provider.svelte.test.ts` — apertura por
`contextmenu`, anchor virtual, scoping de items, selección, checkbox/radio,
submenu y group/separator.
- `menubar/menubar-provider.svelte.test.ts` — coordinación de menús hermanos,
hover-follow, navegación horizontal y cambio de menú desde content.
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/dropdown/context-menu. `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`.
navegacion superior/treegrid/dropdown/context-menu/menubar. `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

@ -0,0 +1,264 @@
// @vitest-environment jsdom
import { flushSync } 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 { FloatingAnchor, FloatingProvider } from '../../layers/floating';
import { MenuProvider as DropdownMenuProvider } from '../dropdown-menu/dropdown-menu-provider.svelte';
import {
MenubarMenuProvider,
MenubarProvider,
MenubarTriggerProvider
} from './menubar-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 timers = {
schedule: vi.fn((key: string, delay: number, callback: () => void) => {
const timer = { key, delay, callback, cancel: vi.fn() };
return timer;
})
};
const soma = {
dom,
uix: { timers },
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(FloatingProvider, 'create').mockReturnValue({} as FloatingProvider);
vi.spyOn(FloatingAnchor, 'create').mockReturnValue({} as FloatingAnchor);
vi.spyOn(MenubarProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(MenubarMenuProvider.ctx, 'set').mockImplementation((value) => value);
vi.spyOn(DropdownMenuProvider.ctx, 'set').mockImplementation((value) => value);
return { dom };
}
function createMenubarOpts() {
return {
id: state('menubar-root'),
ref: state<HTMLElement | null>(document.createElement('div')),
value: state(''),
dir: state<'ltr' | 'rtl'>('ltr'),
loop: state(true)
};
}
function menuOpts(value: string, onOpenChange = vi.fn()) {
return {
value: state(value),
onOpenChange: state(onOpenChange),
onOpenChangeComplete: state<(open: boolean) => void>(() => undefined)
};
}
function triggerOpts(id: string, disabled = false) {
return {
id: state(id),
ref: state<HTMLElement | null>(document.createElement('button')),
disabled: state(disabled)
};
}
function pointerEvent(button = 0, ctrlKey = false) {
return {
button,
ctrlKey,
preventDefault: vi.fn()
};
}
function keyEvent(key: string, target: HTMLElement = document.createElement('div')) {
return {
key,
target,
preventDefault: vi.fn()
};
}
function createMenuWithTrigger(value: string, disabled = false) {
const openChange = vi.fn();
const menu = MenubarMenuProvider.create(menuOpts(value, openChange));
vi.spyOn(MenubarMenuProvider, 'require').mockReturnValue(menu);
const trigger = MenubarTriggerProvider.create(triggerOpts(`menubar-${value}`, disabled));
menu.menu.contentId.current = `menubar-${value}-content`;
return { menu, trigger, openChange };
}
describe('MenubarProvider', () => {
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = '';
});
it('coordinates sibling menus through the root value', () => {
const { dom } = installSomaHarness();
const opts = createMenubarOpts();
const focusSpy = vi.spyOn(dom, 'focus');
const { result, cleanup } = withEffectRoot(() => {
const provider = MenubarProvider.create(opts);
vi.spyOn(MenubarProvider.ctx, 'get').mockReturnValue(provider);
const file = createMenuWithTrigger('file');
const edit = createMenuWithTrigger('edit');
return { provider, file, edit };
});
flushSync();
expect(result.provider.props).toMatchObject({
role: 'menubar',
'aria-orientation': 'horizontal',
dir: 'ltr'
});
expect(result.file.trigger.props).toMatchObject({
type: 'button',
role: 'menuitem',
'aria-haspopup': 'menu',
'aria-expanded': false,
'data-state': 'closed',
'data-menubar-value': 'file',
tabindex: 0
});
expect(result.edit.trigger.props.tabindex).toBe(-1);
const down = pointerEvent();
result.file.trigger.onpointerdown(down as never);
expect(down.preventDefault).toHaveBeenCalledOnce();
expect(opts.value.current).toBe('file');
expect(result.file.openChange).toHaveBeenCalledWith(true);
expect(result.file.trigger.props).toMatchObject({
'aria-expanded': true,
'aria-controls': 'menubar-file-content',
'data-state': 'open',
tabindex: 0
});
result.edit.trigger.onpointerenter();
expect(opts.value.current).toBe('edit');
expect(result.file.openChange).toHaveBeenCalledWith(false);
expect(result.edit.openChange).toHaveBeenCalledWith(true);
expect(focusSpy).toHaveBeenCalledWith(result.edit.trigger.opts.ref.current);
const close = pointerEvent();
result.edit.trigger.onpointerdown(close as never);
expect(close.preventDefault).not.toHaveBeenCalled();
expect(opts.value.current).toBe('');
expect(result.edit.openChange).toHaveBeenCalledWith(false);
cleanup();
dom.dispose();
});
it('navigates enabled triggers in DOM order and skips disabled nodes', () => {
const { dom } = installSomaHarness();
const opts = createMenubarOpts();
const focusSpy = vi.spyOn(dom, 'focus');
const { result, cleanup } = withEffectRoot(() => {
const provider = MenubarProvider.create(opts);
vi.spyOn(MenubarProvider.ctx, 'get').mockReturnValue(provider);
const file = createMenuWithTrigger('file');
const edit = createMenuWithTrigger('edit', true);
edit.trigger.opts.ref.current?.setAttribute('data-disabled', '');
const view = createMenuWithTrigger('view');
return { file, view };
});
flushSync();
const next = keyEvent('ArrowRight', result.file.trigger.opts.ref.current!);
result.file.trigger.onkeydown(next as never);
expect(next.preventDefault).toHaveBeenCalledOnce();
expect(focusSpy).toHaveBeenCalledWith(result.view.trigger.opts.ref.current);
const end = keyEvent('End', result.file.trigger.opts.ref.current!);
result.file.trigger.onkeydown(end as never);
expect(end.preventDefault).toHaveBeenCalledOnce();
expect(focusSpy).toHaveBeenLastCalledWith(result.view.trigger.opts.ref.current);
const home = keyEvent('Home', result.view.trigger.opts.ref.current!);
result.view.trigger.onkeydown(home as never);
expect(home.preventDefault).toHaveBeenCalledOnce();
expect(focusSpy).toHaveBeenLastCalledWith(result.file.trigger.opts.ref.current);
cleanup();
dom.dispose();
});
it('opens a trigger from keyboard activation without moving focus', () => {
const { dom } = installSomaHarness();
const opts = createMenubarOpts();
const focusSpy = vi.spyOn(dom, 'focus');
const { result, cleanup } = withEffectRoot(() => {
const provider = MenubarProvider.create(opts);
vi.spyOn(MenubarProvider.ctx, 'get').mockReturnValue(provider);
const file = createMenuWithTrigger('file');
return { file };
});
flushSync();
const event = keyEvent('ArrowDown', result.file.trigger.opts.ref.current!);
result.file.trigger.onkeydown(event as never);
expect(event.preventDefault).toHaveBeenCalledOnce();
expect(opts.value.current).toBe('file');
expect(focusSpy).not.toHaveBeenCalled();
cleanup();
dom.dispose();
});
it('switches open menu from content horizontal navigation', () => {
const { dom } = installSomaHarness();
const opts = createMenubarOpts();
const focusSpy = vi.spyOn(dom, 'focus');
const { result, cleanup } = withEffectRoot(() => {
const provider = MenubarProvider.create(opts);
vi.spyOn(MenubarProvider.ctx, 'get').mockReturnValue(provider);
const file = createMenuWithTrigger('file');
const edit = createMenuWithTrigger('edit');
provider.open('file');
return { file, edit };
});
flushSync();
const target = document.createElement('div');
const event = keyEvent('ArrowRight', target);
result.file.menu.onContentKeydown(event as never);
expect(event.preventDefault).toHaveBeenCalledOnce();
expect(opts.value.current).toBe('edit');
expect(focusSpy).toHaveBeenCalledWith(result.edit.trigger.opts.ref.current);
const subTrigger = document.createElement('div');
subTrigger.setAttribute('data-dropdown-menu-sub-trigger', '');
const subEvent = keyEvent('ArrowRight', subTrigger);
result.edit.menu.onContentKeydown(subEvent as never);
expect(subEvent.preventDefault).not.toHaveBeenCalled();
cleanup();
dom.dispose();
});
});
Loading…
Cancel
Save

Powered by TurnKey Linux.