Add scroll area provider coverage

active-uix
dev 5 months ago
parent 7f5e46a66e
commit 445c5387f0

@ -111,9 +111,11 @@ Actualizacion 2026-05-15:
y separator.
- Cobertura nueva para Stepper: conteo/estado de steps, attrs runtime,
selección lineal/no lineal, teclado y prev/next/completed.
- Cobertura nueva para ScrollArea: medición de overflow, viewport,
visibilidad/timers de scrollbar, track click, thumb y corner.
- Validado por fase:
- `npm run check` -> 0 errores, 0 warnings.
- `npm run test` -> 216 archivos, 2197 tests OK.
- `npm run test` -> 217 archivos, 2200 tests OK.
- tests focales de Select/Popover/Toast/RangeCalendar -> 11 tests OK.
- tests focales Dialog/Drawer/Popover/Select -> 10 tests OK.
- `command-provider.svelte.test.ts` -> 2 tests OK.
@ -158,6 +160,7 @@ Actualizacion 2026-05-15:
- `breadcrumb-provider.svelte.test.ts` -> 3 tests OK.
- `toolbar-provider.svelte.test.ts` -> 3 tests OK.
- `stepper-provider.svelte.test.ts` -> 3 tests OK.
- `scroll-area-provider.svelte.test.ts` -> 3 tests OK.
- guardia `rg "from './.*provider\\.svelte'" src/uix/soma/components -g 'exports.ts'`
-> solo queda `select/components/select-provider.svelte`, que es wrapper
publico, no runtime provider.

@ -543,6 +543,8 @@ Los tests por componente estan creciendo desde las piezas de mayor riesgo. A
single/multiple y separator.
- `stepper/stepper-provider.svelte.test.ts` — conteo/estado de steps,
attrs runtime, selección lineal/no lineal, teclado y prev/next/completed.
- `scroll-area/scroll-area-provider.svelte.test.ts` — medición de overflow,
viewport, visibilidad/timers de scrollbar, track click, thumb y corner.
Pendiente: seguir ampliando cobertura por el resto del catalogo Soma, ya por
componentes de riesgo medio y familias menos centrales. `table-core`,

@ -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…
Cancel
Save

Powered by TurnKey Linux.