parent
5c434a0cf2
commit
ad5a9627c7
@ -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…
Reference in new issue