parent
7f5e46a66e
commit
445c5387f0
@ -0,0 +1,353 @@
|
||||
// @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 {
|
||||
ScrollAreaCornerProvider,
|
||||
ScrollAreaProvider,
|
||||
ScrollAreaScrollbarProvider,
|
||||
ScrollAreaThumbProvider,
|
||||
ScrollAreaViewportProvider
|
||||
} from './scroll-area-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,
|
||||
langs: {
|
||||
ts: vi.fn((key: string) =>
|
||||
key.includes('horizontal') ? 'Horizontal scrollbar' : 'Vertical scrollbar'
|
||||
)
|
||||
},
|
||||
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(ScrollAreaProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
vi.spyOn(ScrollAreaScrollbarProvider.ctx, 'set').mockImplementation((value) => value);
|
||||
|
||||
return { dom, schedule, tasks, handles };
|
||||
}
|
||||
|
||||
function scrollAreaOpts(root = document.createElement('div')) {
|
||||
return {
|
||||
id: state('scroll-area-root'),
|
||||
ref: state<HTMLElement | null>(root),
|
||||
type: state<'hover' | 'scroll' | 'auto' | 'always'>('scroll'),
|
||||
scrollHideDelay: state(600),
|
||||
dir: state<'ltr' | 'rtl'>('ltr')
|
||||
};
|
||||
}
|
||||
|
||||
function defineBox(
|
||||
el: HTMLElement,
|
||||
box: {
|
||||
clientWidth?: number;
|
||||
clientHeight?: number;
|
||||
scrollWidth?: number;
|
||||
scrollHeight?: number;
|
||||
scrollTop?: number;
|
||||
scrollLeft?: number;
|
||||
}
|
||||
) {
|
||||
for (const [key, value] of Object.entries(box)) {
|
||||
Object.defineProperty(el, key, {
|
||||
value,
|
||||
writable: key === 'scrollTop' || key === 'scrollLeft',
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
stopPropagation: vi.fn(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('ScrollAreaProvider', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('measures viewport overflow and exposes root/viewport state props', () => {
|
||||
const { dom } = installSomaHarness();
|
||||
const observeResize = vi.spyOn(dom, 'observeResize').mockImplementation(() => vi.fn());
|
||||
const root = document.createElement('div');
|
||||
const viewportEl = document.createElement('div');
|
||||
const contentEl = document.createElement('div');
|
||||
viewportEl.appendChild(contentEl);
|
||||
document.body.append(root, viewportEl);
|
||||
defineBox(viewportEl, {
|
||||
clientWidth: 100,
|
||||
clientHeight: 120,
|
||||
scrollWidth: 260,
|
||||
scrollHeight: 420,
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0
|
||||
});
|
||||
const opts = scrollAreaOpts(root);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = ScrollAreaProvider.create(opts);
|
||||
vi.spyOn(ScrollAreaProvider, 'require').mockReturnValue(provider);
|
||||
const viewport = ScrollAreaViewportProvider.create({
|
||||
id: state('scroll-area-viewport'),
|
||||
ref: state<HTMLElement | null>(viewportEl)
|
||||
});
|
||||
provider.setupViewport(viewportEl);
|
||||
return { provider, viewport };
|
||||
});
|
||||
|
||||
expect(observeResize).toHaveBeenCalledTimes(2);
|
||||
expect(result.provider.viewportRef.current).toBe(viewportEl);
|
||||
expect(result.provider.hasOverflowX).toBe(true);
|
||||
expect(result.provider.hasOverflowY).toBe(true);
|
||||
expect(result.provider.props).toMatchObject({
|
||||
id: 'scroll-area-root',
|
||||
'data-scroll-area': '',
|
||||
dir: 'ltr',
|
||||
'data-overflow-x': '',
|
||||
'data-overflow-y': ''
|
||||
});
|
||||
expect(result.provider.props.style).toMatchObject({
|
||||
position: 'relative',
|
||||
'--soma-scroll-area-corner-width': 'var(--soma-scroll-area-scrollbar-size, 8px)',
|
||||
'--soma-scroll-area-corner-height': 'var(--soma-scroll-area-scrollbar-size, 8px)'
|
||||
});
|
||||
expect(result.viewport.props).toMatchObject({
|
||||
id: 'scroll-area-viewport',
|
||||
'data-scroll-area-viewport': '',
|
||||
'data-at-top': '',
|
||||
'data-at-left': '',
|
||||
'data-overflow-x': '',
|
||||
'data-overflow-y': ''
|
||||
});
|
||||
|
||||
viewportEl.scrollTop = 150;
|
||||
viewportEl.scrollLeft = 40;
|
||||
result.viewport.onscroll({ currentTarget: viewportEl } as never);
|
||||
expect(result.provider.scrollTop).toBe(150);
|
||||
expect(result.provider.scrollLeft).toBe(40);
|
||||
expect(result.provider.getScrollProgressY()).toBeCloseTo(0.5);
|
||||
expect(result.provider.getScrollProgressX()).toBeCloseTo(0.25);
|
||||
expect(result.viewport.props['data-at-top']).toBeUndefined();
|
||||
expect(result.viewport.props['data-at-left']).toBeUndefined();
|
||||
|
||||
cleanup();
|
||||
dom.dispose();
|
||||
});
|
||||
|
||||
it('controls scrollbar visibility, labels and track clicks with UIX timers', async () => {
|
||||
const harness = installSomaHarness();
|
||||
let mountedFrame: FrameRequestCallback | undefined;
|
||||
vi.spyOn(harness.dom, 'requestFrame').mockImplementation((callback) => {
|
||||
mountedFrame = callback;
|
||||
return 7;
|
||||
});
|
||||
const cancelFrame = vi.spyOn(harness.dom, 'cancelFrame');
|
||||
const root = document.createElement('div');
|
||||
const viewportEl = document.createElement('div');
|
||||
const scrollbarEl = document.createElement('div');
|
||||
viewportEl.id = 'scroll-area-viewport';
|
||||
document.body.append(root, viewportEl, scrollbarEl);
|
||||
defineBox(viewportEl, {
|
||||
clientWidth: 100,
|
||||
clientHeight: 100,
|
||||
scrollWidth: 100,
|
||||
scrollHeight: 500,
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0
|
||||
});
|
||||
defineBox(scrollbarEl, { clientHeight: 100 });
|
||||
scrollbarEl.getBoundingClientRect = () =>
|
||||
({ top: 0, left: 0, width: 8, height: 100 }) as DOMRect;
|
||||
const opts = scrollAreaOpts(root);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = ScrollAreaProvider.create(opts);
|
||||
vi.spyOn(ScrollAreaProvider, 'require').mockReturnValue(provider);
|
||||
provider.setupViewport(viewportEl);
|
||||
const scrollbar = ScrollAreaScrollbarProvider.create({
|
||||
id: state('scroll-area-scrollbar-y'),
|
||||
ref: state<HTMLElement | null>(scrollbarEl),
|
||||
orientation: state<'vertical'>('vertical')
|
||||
});
|
||||
return { provider, scrollbar };
|
||||
});
|
||||
|
||||
mountedFrame?.(0);
|
||||
result.scrollbar.show();
|
||||
expect(result.scrollbar.shouldShow).toBe(true);
|
||||
expect(result.scrollbar.props).toMatchObject({
|
||||
id: 'scroll-area-scrollbar-y',
|
||||
role: 'scrollbar',
|
||||
'aria-controls': 'scroll-area-viewport',
|
||||
'aria-valuenow': 0,
|
||||
'aria-label': 'Vertical scrollbar',
|
||||
'data-state': 'visible',
|
||||
'data-orientation': 'vertical',
|
||||
'data-overflow-y': ''
|
||||
});
|
||||
|
||||
result.scrollbar.onpointerleave();
|
||||
expect(harness.schedule).toHaveBeenCalledWith(
|
||||
'soma:scroll-area:scroll-area-scrollbar-y:hide:vertical',
|
||||
600,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
replace: true,
|
||||
meta: { component: 'scroll-area', action: 'hide-scrollbar' }
|
||||
})
|
||||
);
|
||||
harness.tasks.get('soma:scroll-area:scroll-area-scrollbar-y:hide:vertical')?.();
|
||||
expect(result.scrollbar.shouldShow).toBe(false);
|
||||
|
||||
result.scrollbar.onclick({ clientY: 50 } as MouseEvent);
|
||||
expect(viewportEl.scrollTop).toBe(200);
|
||||
|
||||
await tick();
|
||||
cleanup();
|
||||
await tick();
|
||||
expect(cancelFrame).toHaveBeenCalledWith(7, scrollbarEl);
|
||||
harness.dom.dispose();
|
||||
});
|
||||
|
||||
it('drags thumbs and exposes corner state from overflow', () => {
|
||||
const harness = installSomaHarness();
|
||||
vi.spyOn(harness.dom, 'requestFrame').mockImplementation((callback) => {
|
||||
callback(0);
|
||||
return 11;
|
||||
});
|
||||
const root = document.createElement('div');
|
||||
const viewportEl = document.createElement('div');
|
||||
const scrollbarEl = document.createElement('div');
|
||||
const thumbEl = document.createElement('div');
|
||||
const cornerEl = document.createElement('div');
|
||||
const setPointerCapture = vi.fn();
|
||||
thumbEl.setPointerCapture = setPointerCapture;
|
||||
document.body.append(root, viewportEl, scrollbarEl, thumbEl, cornerEl);
|
||||
defineBox(viewportEl, {
|
||||
clientWidth: 100,
|
||||
clientHeight: 100,
|
||||
scrollWidth: 300,
|
||||
scrollHeight: 500,
|
||||
scrollTop: 100,
|
||||
scrollLeft: 0
|
||||
});
|
||||
defineBox(scrollbarEl, {
|
||||
clientWidth: 8,
|
||||
clientHeight: 100
|
||||
});
|
||||
const opts = scrollAreaOpts(root);
|
||||
|
||||
const { result, cleanup } = withEffectRoot(() => {
|
||||
const provider = ScrollAreaProvider.create(opts);
|
||||
vi.spyOn(ScrollAreaProvider, 'require').mockReturnValue(provider);
|
||||
provider.setupViewport(viewportEl);
|
||||
provider.handleScroll(viewportEl);
|
||||
const scrollbar = ScrollAreaScrollbarProvider.create({
|
||||
id: state('scroll-area-scrollbar-y'),
|
||||
ref: state<HTMLElement | null>(scrollbarEl),
|
||||
orientation: state<'vertical'>('vertical')
|
||||
});
|
||||
vi.spyOn(ScrollAreaScrollbarProvider, 'require').mockReturnValue(scrollbar);
|
||||
scrollbar.show();
|
||||
const thumb = ScrollAreaThumbProvider.create({
|
||||
id: state('scroll-area-thumb-y'),
|
||||
ref: state<HTMLElement | null>(thumbEl)
|
||||
});
|
||||
const corner = ScrollAreaCornerProvider.create({
|
||||
id: state('scroll-area-corner'),
|
||||
ref: state<HTMLElement | null>(cornerEl)
|
||||
});
|
||||
return { provider, scrollbar, thumb, corner };
|
||||
});
|
||||
|
||||
expect(result.thumb.props.style).toMatchObject({
|
||||
top: '20px',
|
||||
height: '20px',
|
||||
width: '100%',
|
||||
'--soma-scroll-area-thumb-width': '100%',
|
||||
'--soma-scroll-area-thumb-height': '20px'
|
||||
});
|
||||
expect(result.corner.shouldShow).toBe(true);
|
||||
expect(result.corner.props).toMatchObject({
|
||||
id: 'scroll-area-corner',
|
||||
'data-scroll-area-corner': '',
|
||||
'data-state': 'visible'
|
||||
});
|
||||
|
||||
const down = pointerEvent({ currentTarget: thumbEl, clientY: 10, pointerId: 3 });
|
||||
result.thumb.onpointerdown(down as never);
|
||||
expect(down.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(down.stopPropagation).toHaveBeenCalledOnce();
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(3);
|
||||
expect(result.scrollbar.dragging).toBe(true);
|
||||
|
||||
const move = pointerEvent({ clientY: 50 });
|
||||
result.thumb.onpointermove(move as never);
|
||||
expect(move.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(viewportEl.scrollTop).toBe(300);
|
||||
|
||||
result.thumb.onpointerup(pointerEvent() as never);
|
||||
expect(result.scrollbar.dragging).toBe(false);
|
||||
expect(harness.schedule).toHaveBeenCalled();
|
||||
|
||||
cleanup();
|
||||
harness.dom.dispose();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue