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