parent
2956895e04
commit
2255d9f706
@ -0,0 +1,304 @@
|
||||
// @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 {
|
||||
DragDropProvider,
|
||||
DraggableProvider,
|
||||
DragPreviewProvider,
|
||||
DroppableProvider
|
||||
} from './drag-drop-provider.svelte';
|
||||
import type { DragData } from './types';
|
||||
|
||||
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) => ({
|
||||
cancel: vi.fn(),
|
||||
reschedule: vi.fn(),
|
||||
callback
|
||||
}))
|
||||
};
|
||||
const soma = {
|
||||
dom,
|
||||
langs: {
|
||||
ts: vi.fn((key: string) => key),
|
||||
t: vi.fn((key: string, vars: Record<string, string>) =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [name, value]) => text.replace(`{{${name}}}`, value),
|
||||
key
|
||||
)
|
||||
)
|
||||
},
|
||||
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(DragDropProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom, timers };
|
||||
}
|
||||
|
||||
function createRoot() {
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('data-drag-drop', '');
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function createDropZone(value: string, text: string) {
|
||||
const el = document.createElement('div');
|
||||
el.setAttribute('data-drag-drop-droppable', '');
|
||||
el.setAttribute('data-value', value);
|
||||
el.setAttribute('data-text-value', text);
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function providerOpts(root = createRoot()) {
|
||||
return {
|
||||
id: state('drag-drop-root'),
|
||||
ref: state<HTMLElement | null>(root),
|
||||
onDragStart: state<
|
||||
((e: { value: string; data: DragData; preventDefault: () => void }) => void) | undefined
|
||||
>(undefined),
|
||||
onDragEnd: state(vi.fn()),
|
||||
onDrop: state(vi.fn()),
|
||||
announceEnabled: state(true)
|
||||
};
|
||||
}
|
||||
|
||||
function draggableOpts(el: HTMLElement, value = 'card-a') {
|
||||
return {
|
||||
id: state(`drag-${value}`),
|
||||
ref: state<HTMLElement | null>(el),
|
||||
value: state(value),
|
||||
data: state<DragData>({ kind: 'card' }),
|
||||
disabled: state(false),
|
||||
moveBuffer: state(5),
|
||||
textValue: state<string | undefined>('Card A')
|
||||
};
|
||||
}
|
||||
|
||||
function droppableOpts(
|
||||
id: string,
|
||||
el: HTMLElement,
|
||||
accept: ((data: DragData, value: string) => boolean) | undefined = undefined
|
||||
) {
|
||||
return {
|
||||
id: state(id),
|
||||
ref: state<HTMLElement | null>(el),
|
||||
accept: state(accept),
|
||||
disabled: state(false),
|
||||
textValue: state<string | undefined>(el.getAttribute('data-text-value') ?? undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function keyboardEvent(key: string) {
|
||||
return new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
}
|
||||
|
||||
describe('DragDropProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('starts keyboard dragging, navigates accepting targets and commits drop', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const focusSpy = vi.spyOn(dom, 'focus');
|
||||
const root = createRoot();
|
||||
const sourceEl = document.createElement('div');
|
||||
const firstTarget = createDropZone('todo', 'Todo');
|
||||
const secondTarget = createDropZone('done', 'Done');
|
||||
root.append(sourceEl, firstTarget, secondTarget);
|
||||
const opts = providerOpts(root);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = DragDropProvider.create(opts);
|
||||
vi.spyOn(DragDropProvider, 'require').mockReturnValue(provider);
|
||||
const draggable = DraggableProvider.create(draggableOpts(sourceEl));
|
||||
const first = DroppableProvider.create(droppableOpts('drop-todo', firstTarget));
|
||||
const second = DroppableProvider.create(droppableOpts('drop-done', secondTarget));
|
||||
const preview = DragPreviewProvider.create({
|
||||
id: state('drag-preview'),
|
||||
ref: state<HTMLElement | null>(document.createElement('div'))
|
||||
});
|
||||
return { provider, draggable, first, second, preview };
|
||||
});
|
||||
|
||||
await tick();
|
||||
const space = keyboardEvent(' ');
|
||||
result.draggable.onkeydown(space);
|
||||
expect(space.defaultPrevented).toBe(true);
|
||||
expect(result.provider.active?.value).toBe('card-a');
|
||||
expect(result.provider.keyboard).toBe(true);
|
||||
expect(result.provider.overTarget).toBe(firstTarget);
|
||||
expect(focusSpy).toHaveBeenLastCalledWith(firstTarget);
|
||||
expect(result.provider.props['data-dragging']).toBe('');
|
||||
expect(result.draggable.props).toMatchObject({
|
||||
role: 'button',
|
||||
tabindex: 0,
|
||||
'aria-grabbed': true,
|
||||
'data-dragging': '',
|
||||
'data-value': 'card-a'
|
||||
});
|
||||
expect(result.first.props).toMatchObject({
|
||||
role: 'region',
|
||||
tabindex: 0,
|
||||
'aria-dropeffect': 'move',
|
||||
'data-dragover': '',
|
||||
'data-accepting': ''
|
||||
});
|
||||
expect(result.preview.props).toMatchObject({
|
||||
'aria-hidden': true,
|
||||
'data-active': ''
|
||||
});
|
||||
|
||||
window.dispatchEvent(keyboardEvent('ArrowDown'));
|
||||
expect(result.provider.overTarget).toBe(secondTarget);
|
||||
expect(focusSpy).toHaveBeenLastCalledWith(secondTarget);
|
||||
|
||||
window.dispatchEvent(keyboardEvent('Enter'));
|
||||
expect(opts.onDrop.current).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: 'card-a',
|
||||
data: { kind: 'card' },
|
||||
target: secondTarget,
|
||||
targetLabel: 'Done'
|
||||
})
|
||||
);
|
||||
expect(opts.onDragEnd.current).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
value: 'card-a',
|
||||
outcome: 'drop',
|
||||
target: secondTarget
|
||||
})
|
||||
);
|
||||
expect(result.provider.active).toBeNull();
|
||||
expect(result.provider.keyboard).toBe(false);
|
||||
expect(result.provider.props['data-dragging']).toBeUndefined();
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('honors drag prevention, accept filters, cancel outcome and pointer preview style', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = createRoot();
|
||||
const sourceEl = document.createElement('div');
|
||||
sourceEl.getBoundingClientRect = () =>
|
||||
({ left: 10, top: 20, width: 100, height: 30 }) as DOMRect;
|
||||
const blockedTarget = createDropZone('blocked', 'Blocked');
|
||||
const allowedTarget = createDropZone('allowed', 'Allowed');
|
||||
root.append(sourceEl, blockedTarget, allowedTarget);
|
||||
const opts = providerOpts(root);
|
||||
opts.onDragStart.current = (event) => event.preventDefault();
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = DragDropProvider.create(opts);
|
||||
vi.spyOn(DragDropProvider, 'require').mockReturnValue(provider);
|
||||
const blocked = DroppableProvider.create(
|
||||
droppableOpts('drop-blocked', blockedTarget, () => false)
|
||||
);
|
||||
const allowed = DroppableProvider.create(
|
||||
droppableOpts('drop-allowed', allowedTarget, () => true)
|
||||
);
|
||||
const preview = DragPreviewProvider.create({
|
||||
id: state('drag-preview'),
|
||||
ref: state<HTMLElement | null>(document.createElement('div'))
|
||||
});
|
||||
return { provider, blocked, allowed, preview };
|
||||
});
|
||||
|
||||
await tick();
|
||||
expect(
|
||||
result.provider.startDrag({
|
||||
value: 'card-a',
|
||||
data: { kind: 'card' },
|
||||
label: 'Card A',
|
||||
source: sourceEl,
|
||||
pointer: { x: 20, y: 30 },
|
||||
keyboard: false
|
||||
})
|
||||
).toBe(false);
|
||||
expect(result.provider.active).toBeNull();
|
||||
|
||||
opts.onDragStart.current = undefined;
|
||||
expect(
|
||||
result.provider.startDrag({
|
||||
value: 'card-a',
|
||||
data: { kind: 'card' },
|
||||
label: 'Card A',
|
||||
source: sourceEl,
|
||||
pointer: { x: 20, y: 30 },
|
||||
keyboard: false
|
||||
})
|
||||
).toBe(true);
|
||||
expect(result.blocked.isAccepting).toBe(false);
|
||||
expect(result.allowed.isAccepting).toBe(true);
|
||||
expect(result.preview.props).toMatchObject({
|
||||
'data-active': '',
|
||||
style: expect.objectContaining({
|
||||
position: 'fixed',
|
||||
left: '32px',
|
||||
top: '42px',
|
||||
'pointer-events': 'none'
|
||||
})
|
||||
});
|
||||
|
||||
result.provider.commitDrop(blockedTarget);
|
||||
expect(opts.onDragEnd.current).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
value: 'card-a',
|
||||
outcome: 'cancel'
|
||||
})
|
||||
);
|
||||
expect(result.provider.active).toBeNull();
|
||||
|
||||
result.provider.startDrag({
|
||||
value: 'card-a',
|
||||
data: { kind: 'card' },
|
||||
label: 'Card A',
|
||||
source: sourceEl,
|
||||
pointer: null,
|
||||
keyboard: true
|
||||
});
|
||||
result.provider.cancelDrag();
|
||||
expect(opts.onDragEnd.current).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
value: 'card-a',
|
||||
outcome: 'cancel'
|
||||
})
|
||||
);
|
||||
expect(result.preview.props.style).toEqual({ display: 'none' });
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue