parent
445c5387f0
commit
4b5d9078ed
@ -0,0 +1,257 @@
|
||||
// @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 {
|
||||
SplitterPanelProvider,
|
||||
SplitterProvider,
|
||||
SplitterResizeTriggerProvider
|
||||
} from './splitter-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 soma = {
|
||||
dom,
|
||||
runtime: (morfo: Morfo, sources: Omit<SomaRuntimeSources, 'dom' | 'eventEngine'>) =>
|
||||
createSomaRuntime(morfo, {
|
||||
dom,
|
||||
translate: (key) => (key.includes('splitter.resize') ? 'Resize panels' : key),
|
||||
...sources
|
||||
})
|
||||
} as unknown as Soma;
|
||||
|
||||
vi.spyOn(Soma, 'require').mockReturnValue(soma);
|
||||
vi.spyOn(SplitterProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom };
|
||||
}
|
||||
|
||||
function splitterOpts(root = document.createElement('div')) {
|
||||
return {
|
||||
id: state('splitter-root'),
|
||||
ref: state<HTMLElement | null>(root),
|
||||
orientation: state<'horizontal' | 'vertical'>('horizontal'),
|
||||
disabled: state(false),
|
||||
dir: state<'ltr' | 'rtl'>('ltr'),
|
||||
keyboardStep: state(5),
|
||||
onResize: state(vi.fn()),
|
||||
onResizeEnd: state(vi.fn())
|
||||
};
|
||||
}
|
||||
|
||||
function panelOpts(panelId: string, el: HTMLElement, defaultSize: number) {
|
||||
return {
|
||||
id: state(`splitter-panel-${panelId}`),
|
||||
ref: state<HTMLElement | null>(el),
|
||||
panelId: state(panelId),
|
||||
defaultSize: state(defaultSize),
|
||||
minSize: state(10),
|
||||
maxSize: state(90),
|
||||
collapsible: state(false),
|
||||
collapsedSize: state(0)
|
||||
};
|
||||
}
|
||||
|
||||
function pointerEvent(
|
||||
overrides: Partial<PointerEvent> & {
|
||||
currentTarget?: EventTarget;
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
button?: number;
|
||||
pointerId?: number;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
button: 0,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
pointerId: 1,
|
||||
currentTarget: document.createElement('div'),
|
||||
preventDefault: vi.fn(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function keyEvent(key: string) {
|
||||
return {
|
||||
key,
|
||||
preventDefault: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
describe('SplitterProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('registers panels, clamps resize and projects root/panel attrs', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = document.createElement('div');
|
||||
const panelAEl = document.createElement('div');
|
||||
const panelBEl = document.createElement('div');
|
||||
document.body.append(root, panelAEl, panelBEl);
|
||||
const opts = splitterOpts(root);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = SplitterProvider.create(opts);
|
||||
vi.spyOn(SplitterProvider, 'require').mockReturnValue(provider);
|
||||
const panelA = SplitterPanelProvider.create(panelOpts('a', panelAEl, 40));
|
||||
const panelB = SplitterPanelProvider.create(panelOpts('b', panelBEl, 60));
|
||||
return { provider, panelA, panelB };
|
||||
});
|
||||
|
||||
expect(result.provider.getSizes()).toEqual([40, 60]);
|
||||
expect(result.panelA.props.style).toMatchObject({
|
||||
'flex-basis': '0px',
|
||||
'flex-grow': 40,
|
||||
'flex-shrink': 0,
|
||||
'min-width': '0px'
|
||||
});
|
||||
expect(result.panelB.isCollapsed).toBe(false);
|
||||
|
||||
result.provider.resizePanels('a', 'b', 70);
|
||||
expect(result.provider.getSizes()).toEqual([90, 10]);
|
||||
expect(opts.onResize.current).toHaveBeenLastCalledWith([90, 10]);
|
||||
|
||||
opts.disabled.current = true;
|
||||
result.provider.resizePanels('a', 'b', -50);
|
||||
expect(result.provider.getSizes()).toEqual([90, 10]);
|
||||
|
||||
await tick();
|
||||
expect(root.getAttribute('data-orientation')).toBe('horizontal');
|
||||
expect(root.getAttribute('data-disabled')).toBe('');
|
||||
expect(panelAEl.getAttribute('data-state')).toBe('expanded');
|
||||
expect(panelAEl.getAttribute('data-orientation')).toBe('horizontal');
|
||||
expect(panelAEl.getAttribute('data-splitter-panel-id')).toBe('a');
|
||||
expect(panelBEl.getAttribute('data-splitter-panel-id')).toBe('b');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('collapses and expands collapsible panels while respecting adjacent constraints', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = document.createElement('div');
|
||||
const panelAEl = document.createElement('div');
|
||||
const panelBEl = document.createElement('div');
|
||||
document.body.append(root, panelAEl, panelBEl);
|
||||
const opts = splitterOpts(root);
|
||||
const firstOpts = panelOpts('a', panelAEl, 30);
|
||||
firstOpts.collapsible.current = true;
|
||||
firstOpts.collapsedSize.current = 0;
|
||||
const secondOpts = panelOpts('b', panelBEl, 70);
|
||||
secondOpts.maxSize.current = 100;
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = SplitterProvider.create(opts);
|
||||
vi.spyOn(SplitterProvider, 'require').mockReturnValue(provider);
|
||||
const panelA = SplitterPanelProvider.create(firstOpts);
|
||||
const panelB = SplitterPanelProvider.create(secondOpts);
|
||||
return { provider, panelA, panelB };
|
||||
});
|
||||
|
||||
result.provider.collapsePanel('a');
|
||||
expect(result.provider.getSizes()).toEqual([0, 100]);
|
||||
expect(result.panelA.isCollapsed).toBe(true);
|
||||
expect(result.panelA.props.style['flex-grow']).toBe(0);
|
||||
expect(opts.onResize.current).toHaveBeenLastCalledWith([0, 100]);
|
||||
|
||||
result.provider.expandPanel('a');
|
||||
expect(result.provider.getSizes()).toEqual([30, 70]);
|
||||
expect(result.panelA.isCollapsed).toBe(false);
|
||||
expect(result.panelB.props.style['flex-grow']).toBe(70);
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('resizes with pointer and keyboard through the resize trigger', async () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const root = document.createElement('div');
|
||||
const panelAEl = document.createElement('div');
|
||||
const panelBEl = document.createElement('div');
|
||||
const triggerEl = document.createElement('div');
|
||||
const setPointerCapture = vi.fn();
|
||||
triggerEl.setPointerCapture = setPointerCapture;
|
||||
root.getBoundingClientRect = () => ({ width: 400, height: 100 }) as DOMRect;
|
||||
document.body.append(root, panelAEl, panelBEl, triggerEl);
|
||||
const opts = splitterOpts(root);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = SplitterProvider.create(opts);
|
||||
vi.spyOn(SplitterProvider, 'require').mockReturnValue(provider);
|
||||
SplitterPanelProvider.create(panelOpts('a', panelAEl, 50));
|
||||
SplitterPanelProvider.create(panelOpts('b', panelBEl, 50));
|
||||
const trigger = SplitterResizeTriggerProvider.create({
|
||||
id: state('splitter-trigger'),
|
||||
ref: state<HTMLElement | null>(triggerEl),
|
||||
panelBefore: state('a'),
|
||||
panelAfter: state('b'),
|
||||
disabled: state(false)
|
||||
});
|
||||
return { provider, trigger };
|
||||
});
|
||||
|
||||
const down = pointerEvent({ currentTarget: triggerEl, clientX: 100, pointerId: 2 });
|
||||
result.trigger.onpointerdown(down as never);
|
||||
expect(down.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(2);
|
||||
expect(result.trigger.dragging).toBe(true);
|
||||
expect(result.provider.resizing).toBe(true);
|
||||
|
||||
const move = pointerEvent({ clientX: 140 });
|
||||
result.trigger.onpointermove(move as never);
|
||||
expect(move.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(result.provider.getSizes()).toEqual([60, 40]);
|
||||
expect(opts.onResize.current).toHaveBeenLastCalledWith([60, 40]);
|
||||
|
||||
result.trigger.onpointerup(pointerEvent() as never);
|
||||
expect(result.trigger.dragging).toBe(false);
|
||||
expect(result.provider.resizing).toBe(false);
|
||||
expect(opts.onResizeEnd.current).toHaveBeenLastCalledWith([60, 40]);
|
||||
|
||||
const next = keyEvent('ArrowRight');
|
||||
result.trigger.onkeydown(next as never);
|
||||
expect(next.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(result.provider.getSizes()).toEqual([65, 35]);
|
||||
expect(opts.onResizeEnd.current).toHaveBeenLastCalledWith([65, 35]);
|
||||
|
||||
const home = keyEvent('Home');
|
||||
result.trigger.onkeydown(home as never);
|
||||
expect(home.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(result.provider.getSizes()).toEqual([10, 90]);
|
||||
|
||||
const end = keyEvent('End');
|
||||
result.trigger.onkeydown(end as never);
|
||||
expect(end.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(result.provider.getSizes()).toEqual([90, 10]);
|
||||
|
||||
await tick();
|
||||
expect(root.getAttribute('data-resizing')).toBeNull();
|
||||
expect(triggerEl.getAttribute('role')).toBe('separator');
|
||||
expect(triggerEl.getAttribute('aria-label')).toBe('Resize panels');
|
||||
expect(triggerEl.getAttribute('aria-valuenow')).toBe('90');
|
||||
expect(triggerEl.getAttribute('aria-valuemin')).toBe('10');
|
||||
expect(triggerEl.getAttribute('aria-valuemax')).toBe('90');
|
||||
expect(triggerEl.getAttribute('data-state')).toBe('inactive');
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue