59e995d0b5
Composables, tests, category barrels, and README for @robonen/vue.
245 lines
6.9 KiB
TypeScript
245 lines
6.9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { effectScope, nextTick } from 'vue';
|
|
import { useWindowScroll } from '.';
|
|
|
|
function setScroll(x: number, y: number): void {
|
|
(globalThis as any).scrollX = x;
|
|
(globalThis as any).scrollY = y;
|
|
}
|
|
|
|
describe(useWindowScroll, () => {
|
|
beforeEach(() => {
|
|
Object.defineProperty(globalThis, 'scrollX', { value: 0, configurable: true, writable: true });
|
|
Object.defineProperty(globalThis, 'scrollY', { value: 0, configurable: true, writable: true });
|
|
});
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
it('reads the initial scroll position', () => {
|
|
setScroll(15, 25);
|
|
const scope = effectScope();
|
|
let result: ReturnType<typeof useWindowScroll>;
|
|
scope.run(() => {
|
|
result = useWindowScroll();
|
|
});
|
|
|
|
expect(result!.x.value).toBe(15);
|
|
expect(result!.y.value).toBe(25);
|
|
scope.stop();
|
|
});
|
|
|
|
it('updates on scroll', async () => {
|
|
const scope = effectScope();
|
|
let result: ReturnType<typeof useWindowScroll>;
|
|
scope.run(() => {
|
|
result = useWindowScroll();
|
|
});
|
|
|
|
setScroll(30, 60);
|
|
globalThis.dispatchEvent(new Event('scroll'));
|
|
await nextTick();
|
|
|
|
expect(result!.x.value).toBe(30);
|
|
expect(result!.y.value).toBe(60);
|
|
scope.stop();
|
|
});
|
|
|
|
it('scrolls the window when writing to x/y', () => {
|
|
const scrollTo = vi.fn();
|
|
vi.stubGlobal('scrollTo', scrollTo);
|
|
|
|
const scope = effectScope();
|
|
let result: ReturnType<typeof useWindowScroll>;
|
|
scope.run(() => {
|
|
result = useWindowScroll();
|
|
});
|
|
|
|
result!.x.value = 100;
|
|
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 100 }));
|
|
result!.y.value = 200;
|
|
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 200 }));
|
|
scope.stop();
|
|
});
|
|
|
|
it('passes the configured behavior when writing to x/y', () => {
|
|
const scrollTo = vi.fn();
|
|
vi.stubGlobal('scrollTo', scrollTo);
|
|
|
|
const scope = effectScope();
|
|
let result: ReturnType<typeof useWindowScroll>;
|
|
scope.run(() => {
|
|
result = useWindowScroll({ behavior: 'smooth' });
|
|
});
|
|
|
|
result!.x.value = 50;
|
|
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ left: 50, behavior: 'smooth' }));
|
|
scope.stop();
|
|
});
|
|
|
|
it('exposes isScrolling that toggles on scroll and resets after idle', async () => {
|
|
vi.useFakeTimers();
|
|
const scope = effectScope();
|
|
let result: ReturnType<typeof useWindowScroll>;
|
|
scope.run(() => {
|
|
result = useWindowScroll({ idle: 50 });
|
|
});
|
|
|
|
expect(result!.isScrolling.value).toBeFalsy();
|
|
|
|
setScroll(10, 10);
|
|
globalThis.dispatchEvent(new Event('scroll'));
|
|
expect(result!.isScrolling.value).toBeTruthy();
|
|
|
|
vi.advanceTimersByTime(60);
|
|
await nextTick();
|
|
expect(result!.isScrolling.value).toBeFalsy();
|
|
|
|
scope.stop();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('calls onScroll and onStop callbacks', async () => {
|
|
vi.useFakeTimers();
|
|
const onScroll = vi.fn();
|
|
const onStop = vi.fn();
|
|
const scope = effectScope();
|
|
scope.run(() => {
|
|
useWindowScroll({ idle: 50, onScroll, onStop });
|
|
});
|
|
|
|
setScroll(5, 5);
|
|
globalThis.dispatchEvent(new Event('scroll'));
|
|
expect(onScroll).toHaveBeenCalledTimes(1);
|
|
expect(onStop).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(60);
|
|
await nextTick();
|
|
expect(onStop).toHaveBeenCalledTimes(1);
|
|
|
|
scope.stop();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('tracks scroll directions', () => {
|
|
const scope = effectScope();
|
|
let result: ReturnType<typeof useWindowScroll>;
|
|
scope.run(() => {
|
|
result = useWindowScroll();
|
|
});
|
|
|
|
setScroll(40, 80);
|
|
globalThis.dispatchEvent(new Event('scroll'));
|
|
expect(result!.directions.right).toBeTruthy();
|
|
expect(result!.directions.bottom).toBeTruthy();
|
|
expect(result!.directions.left).toBeFalsy();
|
|
expect(result!.directions.top).toBeFalsy();
|
|
|
|
setScroll(10, 20);
|
|
globalThis.dispatchEvent(new Event('scroll'));
|
|
expect(result!.directions.left).toBeTruthy();
|
|
expect(result!.directions.top).toBeTruthy();
|
|
expect(result!.directions.right).toBeFalsy();
|
|
expect(result!.directions.bottom).toBeFalsy();
|
|
|
|
scope.stop();
|
|
});
|
|
|
|
it('reports arrivedState at the top/left edges initially', () => {
|
|
const scope = effectScope();
|
|
let result: ReturnType<typeof useWindowScroll>;
|
|
scope.run(() => {
|
|
result = useWindowScroll();
|
|
});
|
|
|
|
expect(result!.arrivedState.top).toBeTruthy();
|
|
expect(result!.arrivedState.left).toBeTruthy();
|
|
scope.stop();
|
|
});
|
|
|
|
it('clears arrivedState.top once scrolled down', () => {
|
|
const scope = effectScope();
|
|
let result: ReturnType<typeof useWindowScroll>;
|
|
scope.run(() => {
|
|
result = useWindowScroll();
|
|
});
|
|
|
|
setScroll(0, 100);
|
|
globalThis.dispatchEvent(new Event('scroll'));
|
|
expect(result!.arrivedState.top).toBeFalsy();
|
|
scope.stop();
|
|
});
|
|
|
|
it('honors the top offset for arrivedState', () => {
|
|
const scope = effectScope();
|
|
let result: ReturnType<typeof useWindowScroll>;
|
|
scope.run(() => {
|
|
result = useWindowScroll({ offset: { top: 30 } });
|
|
});
|
|
|
|
setScroll(0, 20);
|
|
globalThis.dispatchEvent(new Event('scroll'));
|
|
// Within the 30px offset, still considered "arrived at top".
|
|
expect(result!.arrivedState.top).toBeTruthy();
|
|
|
|
setScroll(0, 40);
|
|
globalThis.dispatchEvent(new Event('scroll'));
|
|
expect(result!.arrivedState.top).toBeFalsy();
|
|
scope.stop();
|
|
});
|
|
|
|
it('measure() recomputes state without a scroll event', () => {
|
|
const scope = effectScope();
|
|
let result: ReturnType<typeof useWindowScroll>;
|
|
scope.run(() => {
|
|
result = useWindowScroll();
|
|
});
|
|
|
|
setScroll(0, 70);
|
|
// No event dispatched; values are stale until measure().
|
|
expect(result!.y.value).toBe(0);
|
|
result!.measure();
|
|
expect(result!.y.value).toBe(70);
|
|
scope.stop();
|
|
});
|
|
|
|
it('is SSR-safe when window is undefined', () => {
|
|
const scope = effectScope();
|
|
let result: ReturnType<typeof useWindowScroll>;
|
|
scope.run(() => {
|
|
result = useWindowScroll({ window: undefined });
|
|
});
|
|
|
|
expect(result!.x.value).toBe(0);
|
|
expect(result!.y.value).toBe(0);
|
|
expect(result!.isScrolling.value).toBeFalsy();
|
|
// Writing should be a no-op (no throw).
|
|
expect(() => {
|
|
result!.x.value = 10;
|
|
}).not.toThrow();
|
|
scope.stop();
|
|
});
|
|
|
|
it('throttles the scroll handler when throttle is set', async () => {
|
|
vi.useFakeTimers();
|
|
const onScroll = vi.fn();
|
|
const scope = effectScope();
|
|
scope.run(() => {
|
|
useWindowScroll({ throttle: 100, onScroll });
|
|
});
|
|
|
|
setScroll(1, 1);
|
|
globalThis.dispatchEvent(new Event('scroll'));
|
|
setScroll(2, 2);
|
|
globalThis.dispatchEvent(new Event('scroll'));
|
|
setScroll(3, 3);
|
|
globalThis.dispatchEvent(new Event('scroll'));
|
|
|
|
// Trailing-only throttle: collapses the burst into a single deferred call.
|
|
vi.advanceTimersByTime(120);
|
|
await nextTick();
|
|
expect(onScroll).toHaveBeenCalledTimes(1);
|
|
|
|
scope.stop();
|
|
vi.useRealTimers();
|
|
});
|
|
});
|