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