parent
ccded8bc4a
commit
2956895e04
@ -0,0 +1,248 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createActiveDom } from '$adom';
|
||||
import { state, type 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 {
|
||||
ToastActionProvider,
|
||||
ToastCloseProvider,
|
||||
ToastDescriptionProvider,
|
||||
ToastItemProvider,
|
||||
ToastMainProvider,
|
||||
ToastProvider,
|
||||
ToastStatusProvider,
|
||||
ToastTitleProvider,
|
||||
ToastViewportProvider
|
||||
} from './toast-provider.svelte';
|
||||
import { Toaster, type ToastData } from './toaster.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,
|
||||
langs: {
|
||||
ts: vi.fn((key: string) => (key.includes('close') ? 'Close' : key))
|
||||
},
|
||||
uix: {
|
||||
timers: { schedule }
|
||||
},
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => soma.langs.ts(key),
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(ToastProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
vi.spyOn(ToastItemProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom, schedule, tasks, handles };
|
||||
}
|
||||
|
||||
function toastProviderOpts(toaster: Toaster) {
|
||||
return {
|
||||
id: state('toast-root'),
|
||||
ref: state<HTMLElement | null>(null),
|
||||
toaster: state(toaster),
|
||||
label: state('Notifications')
|
||||
};
|
||||
}
|
||||
|
||||
function itemOpts(toast: State<ToastData>, itemEl: HTMLElement) {
|
||||
return {
|
||||
id: state('toast-item'),
|
||||
ref: state<HTMLElement | null>(itemEl),
|
||||
toast
|
||||
};
|
||||
}
|
||||
|
||||
function keyboardEvent(key: string, code: string, init: KeyboardEventInit = {}) {
|
||||
return new KeyboardEvent('keydown', {
|
||||
key,
|
||||
code,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init
|
||||
});
|
||||
}
|
||||
|
||||
describe('ToastProvider parts', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('projects viewport attrs and focuses it through the toaster hotkey', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const focusSpy = vi.spyOn(dom, 'focus');
|
||||
const toaster = new Toaster({ duration: 0, hotkey: ['AltLeft', 'F8'] });
|
||||
const viewportEl = document.createElement('div');
|
||||
document.body.appendChild(viewportEl);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = ToastProvider.create(toastProviderOpts(toaster));
|
||||
vi.spyOn(ToastProvider, 'require').mockReturnValue(provider);
|
||||
const viewport = ToastViewportProvider.create({
|
||||
id: state('toast-viewport'),
|
||||
ref: state<HTMLElement | null>(viewportEl)
|
||||
});
|
||||
return { provider, viewport };
|
||||
});
|
||||
|
||||
expect(result.provider.toaster).toBe(toaster);
|
||||
expect(result.viewport.props).toMatchObject({
|
||||
id: 'toast-viewport',
|
||||
'data-toast-viewport': '',
|
||||
tabindex: -1
|
||||
});
|
||||
await tick();
|
||||
expect(viewportEl.getAttribute('role')).toBe('region');
|
||||
expect(viewportEl.getAttribute('aria-label')).toBe('Notifications');
|
||||
expect(viewportEl.getAttribute('aria-live')).toBe('polite');
|
||||
|
||||
document.dispatchEvent(keyboardEvent('Alt', 'AltLeft', { altKey: true }));
|
||||
const f8 = keyboardEvent('F8', 'F8', { altKey: true });
|
||||
document.dispatchEvent(f8);
|
||||
expect(f8.defaultPrevented).toBe(true);
|
||||
expect(focusSpy).toHaveBeenCalledWith(viewportEl);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('uses a per-item runtime for ARIA, timers and dismiss actions', async () => {
|
||||
const harness = installSomaHarness();
|
||||
const toaster = new Toaster({ duration: 100, swipeDirection: 'right', swipeThreshold: 50 });
|
||||
const id = toaster.create({
|
||||
title: 'Saved',
|
||||
description: 'Record persisted',
|
||||
intent: 'risk',
|
||||
loading: true,
|
||||
duration: 100
|
||||
});
|
||||
const toastState = state(toaster.toasts[0]!);
|
||||
const itemEl = document.createElement('div');
|
||||
const titleEl = document.createElement('div');
|
||||
const descriptionEl = document.createElement('div');
|
||||
const actionEl = document.createElement('button');
|
||||
const closeEl = document.createElement('button');
|
||||
const statusEl = document.createElement('span');
|
||||
const mainEl = document.createElement('div');
|
||||
document.body.append(itemEl, titleEl, descriptionEl, actionEl, closeEl, statusEl, mainEl);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = ToastProvider.create(toastProviderOpts(toaster));
|
||||
vi.spyOn(ToastProvider, 'require').mockReturnValue(provider);
|
||||
const item = ToastItemProvider.create(itemOpts(toastState, itemEl));
|
||||
vi.spyOn(ToastItemProvider, 'require').mockReturnValue(item);
|
||||
const title = ToastTitleProvider.create({
|
||||
id: state('toast-title'),
|
||||
ref: state<HTMLElement | null>(titleEl)
|
||||
});
|
||||
const description = ToastDescriptionProvider.create({
|
||||
id: state('toast-description'),
|
||||
ref: state<HTMLElement | null>(descriptionEl)
|
||||
});
|
||||
const action = ToastActionProvider.create({
|
||||
id: state('toast-action'),
|
||||
ref: state<HTMLElement | null>(actionEl),
|
||||
altText: state('Undo save')
|
||||
});
|
||||
const close = ToastCloseProvider.create({
|
||||
id: state('toast-close'),
|
||||
ref: state<HTMLElement | null>(closeEl)
|
||||
});
|
||||
const status = ToastStatusProvider.create({
|
||||
id: state('toast-status'),
|
||||
ref: state<HTMLElement | null>(statusEl)
|
||||
});
|
||||
const main = ToastMainProvider.create({
|
||||
id: state('toast-main'),
|
||||
ref: state<HTMLElement | null>(mainEl)
|
||||
});
|
||||
return { provider, item, title, description, action, close, status, main };
|
||||
});
|
||||
|
||||
expect(harness.schedule).toHaveBeenCalledWith(
|
||||
'soma:toast:auto-dismiss:toast-item',
|
||||
100,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
replace: true,
|
||||
meta: { component: 'toast', action: 'auto-dismiss' }
|
||||
})
|
||||
);
|
||||
expect(result.item.props).toMatchObject({
|
||||
id: 'toast-item',
|
||||
'data-toast-item': '',
|
||||
tabindex: 0
|
||||
});
|
||||
expect(result.title.props).toMatchObject({ id: 'toast-title', 'data-toast-title': '' });
|
||||
expect(result.description.props).toMatchObject({
|
||||
id: 'toast-description',
|
||||
'data-toast-description': ''
|
||||
});
|
||||
expect(result.status.props).toMatchObject({ id: 'toast-status', 'data-toast-status': '' });
|
||||
expect(result.main.props).toMatchObject({ id: 'toast-main', 'data-toast-main': '' });
|
||||
expect(result.close.props.onclick).toBe(result.close.onclick);
|
||||
|
||||
await tick();
|
||||
expect(itemEl.getAttribute('role')).toBe('alert');
|
||||
expect(itemEl.getAttribute('aria-live')).toBe('assertive');
|
||||
expect(itemEl.getAttribute('aria-atomic')).toBe('true');
|
||||
expect(itemEl.getAttribute('aria-labelledby')).toBe('toast-title');
|
||||
expect(itemEl.getAttribute('aria-describedby')).toBe('toast-description');
|
||||
expect(itemEl.getAttribute('data-intent')).toBe('risk');
|
||||
expect(itemEl.getAttribute('data-loading')).toBe('');
|
||||
expect(actionEl.getAttribute('type')).toBe('button');
|
||||
expect(actionEl.getAttribute('aria-label')).toBe('Undo save');
|
||||
expect(closeEl.getAttribute('type')).toBe('button');
|
||||
expect(closeEl.getAttribute('aria-label')).toBe('Close');
|
||||
expect(statusEl.getAttribute('aria-hidden')).toBe('true');
|
||||
|
||||
result.item.onpointerenter();
|
||||
expect(harness.handles.get('soma:toast:auto-dismiss:toast-item')?.cancel).toHaveBeenCalled();
|
||||
result.item.onpointerleave();
|
||||
expect(harness.schedule).toHaveBeenCalledTimes(2);
|
||||
|
||||
result.close.onclick({ currentTarget: closeEl } as never);
|
||||
await tick();
|
||||
expect(toaster.toasts.find((toast) => toast.id === id)?.dismissing).toBe(true);
|
||||
toastState.current = toaster.toasts[0]!;
|
||||
await tick();
|
||||
expect(itemEl.getAttribute('data-state')).toBe('closed');
|
||||
|
||||
cleanup();
|
||||
harness.dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue