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