parent
e22b418324
commit
6a5152a5d7
@ -0,0 +1,271 @@
|
||||
// @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 { FloatingContent, FloatingProvider } from '$soma/layers/floating';
|
||||
|
||||
import {
|
||||
TooltipArrowProvider,
|
||||
TooltipContentProvider,
|
||||
TooltipGroupProvider,
|
||||
TooltipProvider,
|
||||
TooltipTriggerProvider
|
||||
} from './tooltip-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 tasks = new Map<string, () => void>();
|
||||
const handles = new Map<
|
||||
string,
|
||||
{ cancel: ReturnType<typeof vi.fn>; reschedule: ReturnType<typeof vi.fn> }
|
||||
>();
|
||||
const schedule = vi.fn((key: string, _delay: number, task: () => void) => {
|
||||
tasks.set(key, task);
|
||||
const handle = {
|
||||
cancel: vi.fn(() => tasks.delete(key)),
|
||||
reschedule: vi.fn()
|
||||
};
|
||||
handles.set(key, handle);
|
||||
return handle;
|
||||
});
|
||||
const soma = {
|
||||
dom,
|
||||
uix: {
|
||||
timers: { schedule }
|
||||
},
|
||||
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(TooltipGroupProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
vi.spyOn(TooltipProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
vi.spyOn(FloatingProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
vi.spyOn(FloatingContent.ctx, 'set').mockImplementation((value) => value);
|
||||
const groupGet = vi.spyOn(TooltipGroupProvider, 'get').mockReturnValue(undefined);
|
||||
|
||||
return { dom, schedule, tasks, handles, groupGet };
|
||||
}
|
||||
|
||||
function groupOpts() {
|
||||
return {
|
||||
delayDuration: state(700),
|
||||
skipDelayDuration: state(300),
|
||||
disableHoverableContent: state(false),
|
||||
disabled: state(false)
|
||||
};
|
||||
}
|
||||
|
||||
function tooltipOpts() {
|
||||
return {
|
||||
id: state('tooltip-root'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
open: state(false),
|
||||
delayDuration: state<number | undefined>(undefined),
|
||||
closeDelay: state(150),
|
||||
disableHoverableContent: state<boolean | undefined>(undefined),
|
||||
disabled: state<boolean | undefined>(undefined),
|
||||
onOpenChangeComplete: state<(open: boolean) => void>(() => undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function contentOpts(contentEl: HTMLElement) {
|
||||
return {
|
||||
id: state('tooltip-content'),
|
||||
ref: state<HTMLElement | null>(contentEl),
|
||||
side: state<'top'>('top'),
|
||||
sideOffset: state(0),
|
||||
align: state<'center'>('center'),
|
||||
alignOffset: state(0),
|
||||
arrowPadding: state(0),
|
||||
avoidCollisions: state(false),
|
||||
collisionBoundary: state([]),
|
||||
collisionPadding: state(8),
|
||||
sticky: state<'partial'>('partial'),
|
||||
hideWhenDetached: state(false),
|
||||
updatePositionStrategy: state<'optimized'>('optimized'),
|
||||
strategy: state<'fixed'>('fixed'),
|
||||
dir: state<'ltr'>('ltr'),
|
||||
style: state(null),
|
||||
ariaLabel: state<string | undefined>('Helpful hint'),
|
||||
onEscapeKeydown: state<(e: KeyboardEvent) => void>(() => undefined),
|
||||
escapeKeydownBehavior: state<'close'>('close')
|
||||
};
|
||||
}
|
||||
|
||||
describe('TooltipProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('uses delayed open, delayed close and group skip-delay timers', async () => {
|
||||
const harness = installSomaHarness();
|
||||
const triggerEl = document.createElement('button');
|
||||
document.body.appendChild(triggerEl);
|
||||
const opts = tooltipOpts();
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const group = TooltipGroupProvider.create(groupOpts());
|
||||
harness.groupGet.mockReturnValue(group);
|
||||
const provider = TooltipProvider.create(opts);
|
||||
vi.spyOn(TooltipProvider, 'require').mockReturnValue(provider);
|
||||
vi.spyOn(FloatingProvider.ctx, 'get').mockReturnValue(provider.floatingProvider);
|
||||
const trigger = TooltipTriggerProvider.create({
|
||||
id: state('tooltip-trigger'),
|
||||
ref: state<HTMLElement | null>(triggerEl)
|
||||
});
|
||||
return { provider, trigger };
|
||||
});
|
||||
|
||||
result.trigger.onpointerenter();
|
||||
expect(result.provider.tooltipState).toBe('closed');
|
||||
expect(opts.open.current).toBe(false);
|
||||
expect(harness.schedule).toHaveBeenCalledWith(
|
||||
'soma:tooltip:tooltip-root:open',
|
||||
700,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ replace: true })
|
||||
);
|
||||
|
||||
harness.tasks.get('soma:tooltip:tooltip-root:open')?.();
|
||||
await tick();
|
||||
expect(opts.open.current).toBe(true);
|
||||
expect(result.provider.tooltipState).toBe('instant-open');
|
||||
expect(triggerEl.getAttribute('data-state')).toBe('instant-open');
|
||||
|
||||
result.trigger.onpointerleave();
|
||||
expect(harness.schedule).toHaveBeenCalledWith(
|
||||
'soma:tooltip:tooltip-root:close',
|
||||
150,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ replace: true })
|
||||
);
|
||||
harness.tasks.get('soma:tooltip:tooltip-root:close')?.();
|
||||
await tick();
|
||||
expect(opts.open.current).toBe(false);
|
||||
expect(result.provider.tooltipState).toBe('closed');
|
||||
|
||||
result.trigger.onpointerenter();
|
||||
expect(opts.open.current).toBe(true);
|
||||
expect(result.provider.tooltipState).toBe('instant-open');
|
||||
|
||||
cleanup();
|
||||
harness.dom.dispose();
|
||||
});
|
||||
|
||||
it('opens instantly on focus, closes on blur and respects disabled state', async () => {
|
||||
const harness = installSomaHarness();
|
||||
const triggerEl = document.createElement('button');
|
||||
document.body.appendChild(triggerEl);
|
||||
const opts = tooltipOpts();
|
||||
opts.closeDelay.current = 0;
|
||||
const groupConfig = groupOpts();
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const group = TooltipGroupProvider.create(groupConfig);
|
||||
harness.groupGet.mockReturnValue(group);
|
||||
const provider = TooltipProvider.create(opts);
|
||||
vi.spyOn(TooltipProvider, 'require').mockReturnValue(provider);
|
||||
vi.spyOn(FloatingProvider.ctx, 'get').mockReturnValue(provider.floatingProvider);
|
||||
const trigger = TooltipTriggerProvider.create({
|
||||
id: state('tooltip-trigger'),
|
||||
ref: state<HTMLElement | null>(triggerEl)
|
||||
});
|
||||
return { group, provider, trigger };
|
||||
});
|
||||
|
||||
result.trigger.onfocus();
|
||||
await tick();
|
||||
expect(opts.open.current).toBe(true);
|
||||
expect(result.provider.tooltipState).toBe('instant-open');
|
||||
|
||||
result.trigger.onblur();
|
||||
await tick();
|
||||
expect(opts.open.current).toBe(false);
|
||||
expect(result.provider.tooltipState).toBe('closed');
|
||||
|
||||
groupConfig.disabled.current = true;
|
||||
result.trigger.onfocus();
|
||||
await tick();
|
||||
expect(opts.open.current).toBe(false);
|
||||
|
||||
cleanup();
|
||||
harness.dom.dispose();
|
||||
});
|
||||
|
||||
it('registers trigger/content/arrow parts and links content while open', async () => {
|
||||
const harness = installSomaHarness();
|
||||
const triggerEl = document.createElement('button');
|
||||
const contentEl = document.createElement('div');
|
||||
const arrowEl = document.createElement('div');
|
||||
document.body.append(triggerEl, contentEl, arrowEl);
|
||||
const opts = tooltipOpts();
|
||||
opts.open.current = true;
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = TooltipProvider.create(opts);
|
||||
vi.spyOn(TooltipProvider, 'require').mockReturnValue(provider);
|
||||
vi.spyOn(FloatingProvider.ctx, 'get').mockReturnValue(provider.floatingProvider);
|
||||
const trigger = TooltipTriggerProvider.create({
|
||||
id: state('tooltip-trigger'),
|
||||
ref: state<HTMLElement | null>(triggerEl)
|
||||
});
|
||||
const content = TooltipContentProvider.create(contentOpts(contentEl));
|
||||
vi.spyOn(FloatingContent.ctx, 'get').mockReturnValue(content.floating);
|
||||
const arrow = TooltipArrowProvider.create({
|
||||
id: state('tooltip-arrow'),
|
||||
ref: state<HTMLElement | null>(arrowEl)
|
||||
});
|
||||
return { provider, trigger, content, arrow };
|
||||
});
|
||||
|
||||
expect(result.trigger.props).toMatchObject({
|
||||
id: 'tooltip-trigger',
|
||||
'data-tooltip-trigger': '',
|
||||
tabindex: 0
|
||||
});
|
||||
expect(result.content.props).toMatchObject({
|
||||
id: 'tooltip-content',
|
||||
'data-tooltip-content': ''
|
||||
});
|
||||
expect(result.content.wrapperProps).toMatchObject({
|
||||
id: 'tooltip-content-wrapper',
|
||||
'data-soma-floating-wrapper': '',
|
||||
dir: 'ltr'
|
||||
});
|
||||
expect(result.arrow.props).toMatchObject({
|
||||
id: 'tooltip-arrow',
|
||||
'data-tooltip-arrow': ''
|
||||
});
|
||||
expect(result.provider.triggerId.current).toBe('tooltip-trigger');
|
||||
expect(result.provider.contentId.current).toBe('tooltip-content');
|
||||
|
||||
await tick();
|
||||
expect(triggerEl.getAttribute('aria-describedby')).toBe('tooltip-content');
|
||||
expect(contentEl.getAttribute('role')).toBe('tooltip');
|
||||
expect(contentEl.getAttribute('aria-label')).toBe('Helpful hint');
|
||||
expect(contentEl.getAttribute('data-state')).toBe('instant-open');
|
||||
|
||||
cleanup();
|
||||
harness.dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue