fix(vue): eslint/tsconfig migration + resolve type errors
@robonen/vue (toolkit): migrate to eslint flat config + composite tsconfig; fix composable + test type errors (writable computed returns, null guards, overload-compatible signatures, typed test helpers) — all type-level.
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useScroll } from '.';
|
||||
|
||||
function makeScrollable(overrides: Partial<{
|
||||
scrollWidth: number;
|
||||
scrollHeight: number;
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
}> = {}) {
|
||||
const el = document.createElement('div');
|
||||
Object.defineProperties(el, {
|
||||
scrollWidth: { value: overrides.scrollWidth ?? 1000, configurable: true },
|
||||
scrollHeight: { value: overrides.scrollHeight ?? 1000, configurable: true },
|
||||
clientWidth: { value: overrides.clientWidth ?? 100, configurable: true },
|
||||
clientHeight: { value: overrides.clientHeight ?? 100, configurable: true },
|
||||
});
|
||||
el.scrollLeft = 0;
|
||||
el.scrollTop = 0;
|
||||
return el;
|
||||
}
|
||||
|
||||
function withScope<T>(fn: () => T): { result: T; scope: ReturnType<typeof effectScope> } {
|
||||
const scope = effectScope();
|
||||
let result!: T;
|
||||
scope.run(() => {
|
||||
result = fn();
|
||||
});
|
||||
return { result, scope };
|
||||
}
|
||||
|
||||
describe(useScroll, () => {
|
||||
it('starts at the top-left with arrived state', () => {
|
||||
const el = makeScrollable();
|
||||
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||
|
||||
expect(result.x.value).toBe(0);
|
||||
expect(result.y.value).toBe(0);
|
||||
expect(result.arrivedState.top).toBeTruthy();
|
||||
expect(result.arrivedState.left).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('updates position and isScrolling on scroll', async () => {
|
||||
const el = makeScrollable();
|
||||
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||
|
||||
el.scrollTop = 50;
|
||||
el.scrollLeft = 20;
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
await nextTick();
|
||||
|
||||
expect(result.x.value).toBe(20);
|
||||
expect(result.y.value).toBe(50);
|
||||
expect(result.isScrolling.value).toBeTruthy();
|
||||
expect(result.directions.bottom).toBeTruthy();
|
||||
expect(result.directions.right).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('flags arrival at the bottom edge', async () => {
|
||||
const el = makeScrollable();
|
||||
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||
|
||||
el.scrollTop = 900; // 900 + 100 clientHeight >= 1000 scrollHeight
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
await nextTick();
|
||||
|
||||
expect(result.arrivedState.bottom).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('measures the initial scroll position on mount', () => {
|
||||
const el = makeScrollable();
|
||||
el.scrollLeft = 30;
|
||||
el.scrollTop = 40;
|
||||
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||
|
||||
expect(result.x.value).toBe(30);
|
||||
expect(result.y.value).toBe(40);
|
||||
expect(result.arrivedState.top).toBeFalsy();
|
||||
expect(result.arrivedState.left).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('exposes measure() to re-sync without a scroll event', () => {
|
||||
const el = makeScrollable();
|
||||
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||
|
||||
expect(result.y.value).toBe(0);
|
||||
|
||||
el.scrollTop = 200;
|
||||
result.measure();
|
||||
|
||||
expect(result.y.value).toBe(200);
|
||||
// measure() must not fabricate directions.
|
||||
expect(result.directions.bottom).toBeFalsy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('respects the offset when computing arrived state', async () => {
|
||||
const el = makeScrollable();
|
||||
const { result, scope } = withScope(() => useScroll(ref(el), { offset: { top: 10, bottom: 50 } }));
|
||||
|
||||
el.scrollTop = 8; // <= offset.top (10) => still arrived at top
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
await nextTick();
|
||||
expect(result.arrivedState.top).toBeTruthy();
|
||||
|
||||
el.scrollTop = 855; // 855 + 100 >= 1000 - 50 - 1 => arrived at bottom early
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
await nextTick();
|
||||
expect(result.arrivedState.bottom).toBeTruthy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('resets isScrolling and directions and calls onStop after idle', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onStop = vi.fn();
|
||||
const el = makeScrollable();
|
||||
const { result, scope } = withScope(() => useScroll(ref(el), { idle: 100, onStop }));
|
||||
|
||||
el.scrollTop = 50;
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
expect(result.isScrolling.value).toBeTruthy();
|
||||
expect(result.directions.bottom).toBeTruthy();
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
await nextTick();
|
||||
|
||||
expect(result.isScrolling.value).toBeFalsy();
|
||||
expect(result.directions.bottom).toBeFalsy();
|
||||
expect(onStop).toHaveBeenCalledTimes(1);
|
||||
scope.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('normalises a negative (RTL) scrollLeft for arrived state', async () => {
|
||||
const el = makeScrollable();
|
||||
const styleSpy = vi.spyOn(globalThis, 'getComputedStyle').mockReturnValue({ direction: 'rtl' } as CSSStyleDeclaration);
|
||||
const { result, scope } = withScope(() => useScroll(ref(el)));
|
||||
|
||||
// RTL: scrolled to the far end reports a large negative magnitude.
|
||||
el.scrollLeft = -900;
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
await nextTick();
|
||||
|
||||
// |−900| + 100 clientWidth >= 1000 scrollWidth => arrived at the right edge.
|
||||
expect(result.arrivedState.right).toBeTruthy();
|
||||
expect(result.arrivedState.left).toBeFalsy();
|
||||
styleSpy.mockRestore();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('writes scroll position through the x/y setters with behavior', () => {
|
||||
const el = makeScrollable();
|
||||
const scrollToSpy = vi.fn();
|
||||
el.scrollTo = scrollToSpy as typeof el.scrollTo;
|
||||
const { result, scope } = withScope(() => useScroll(ref(el), { behavior: 'smooth' }));
|
||||
|
||||
result.y.value = 120;
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ top: 120, behavior: 'smooth' });
|
||||
|
||||
result.x.value = 60;
|
||||
expect(scrollToSpy).toHaveBeenCalledWith({ left: 60, behavior: 'smooth' });
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('invokes onError when reading metrics throws', () => {
|
||||
const el = makeScrollable();
|
||||
const onError = vi.fn();
|
||||
const styleSpy = vi.spyOn(globalThis, 'getComputedStyle').mockImplementation(() => {
|
||||
throw new Error('detached');
|
||||
});
|
||||
const { scope } = withScope(() => useScroll(ref(el), { onError }));
|
||||
|
||||
expect(onError).toHaveBeenCalled();
|
||||
styleSpy.mockRestore();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('does nothing when target is nullish', () => {
|
||||
const { result, scope } = withScope(() => useScroll(ref(null)));
|
||||
|
||||
expect(result.x.value).toBe(0);
|
||||
expect(result.y.value).toBe(0);
|
||||
expect(() => result.measure()).not.toThrow();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
it('throttles scroll updates when throttle is set', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onScroll = vi.fn();
|
||||
const el = makeScrollable();
|
||||
const { scope } = withScope(() => useScroll(ref(el), { throttle: 100, onScroll }));
|
||||
|
||||
el.scrollTop = 10;
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
el.scrollTop = 20;
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
el.scrollTop = 30;
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
|
||||
// Leading edge fires once immediately, the rest are throttled.
|
||||
expect(onScroll).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
// Trailing edge flushes the latest.
|
||||
expect(onScroll).toHaveBeenCalledTimes(2);
|
||||
|
||||
scope.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { computed, reactive, shallowRef, toValue } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref } from 'vue';
|
||||
import { noop } from '@robonen/stdlib';
|
||||
import type { ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { useMutationObserver } from '@/composables/browser/useMutationObserver';
|
||||
import { useThrottleFn } from '@/composables/utilities/useThrottleFn';
|
||||
import { useDebounceFn } from '@/composables/utilities/useDebounceFn';
|
||||
|
||||
export interface UseScrollOffset {
|
||||
left?: number;
|
||||
right?: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
}
|
||||
|
||||
export interface UseScrollObserveOptions {
|
||||
/**
|
||||
* Re-measure the arrived/position state whenever the target's subtree
|
||||
* mutates (children added/removed, attributes changed). Useful when the
|
||||
* scrollable content grows or shrinks without a scroll event firing.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
mutation?: boolean;
|
||||
}
|
||||
|
||||
export interface UseScrollOptions extends ConfigurableWindow {
|
||||
/**
|
||||
* Throttle delay (ms) for scroll position updates. `0` disables throttling.
|
||||
*
|
||||
* @default 0
|
||||
*/
|
||||
throttle?: number;
|
||||
|
||||
/**
|
||||
* Idle time (ms) before `isScrolling` is reset to `false`
|
||||
*
|
||||
* @default 200
|
||||
*/
|
||||
idle?: number;
|
||||
|
||||
/**
|
||||
* Distance (px) from each edge at which `arrivedState` flips to `true`
|
||||
*
|
||||
* @default { left: 0, right: 0, top: 0, bottom: 0 }
|
||||
*/
|
||||
offset?: UseScrollOffset;
|
||||
|
||||
/**
|
||||
* Called on every scroll event
|
||||
*/
|
||||
onScroll?: (event: Event) => void;
|
||||
|
||||
/**
|
||||
* Called when scrolling stops (after `idle`)
|
||||
*/
|
||||
onStop?: (event: Event) => void;
|
||||
|
||||
/**
|
||||
* Listener options for the scroll event
|
||||
*
|
||||
* @default { capture: false, passive: true }
|
||||
*/
|
||||
eventListenerOptions?: boolean | AddEventListenerOptions;
|
||||
|
||||
/**
|
||||
* Scroll behavior used when writing to `x`/`y`
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
behavior?: ScrollBehavior;
|
||||
|
||||
/**
|
||||
* Re-measure the scroll state on DOM mutations of the target.
|
||||
* Pass `true` to enable the default (`{ mutation: true }`).
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
observe?: boolean | UseScrollObserveOptions;
|
||||
|
||||
/**
|
||||
* Error handler invoked when reading scroll metrics or computed style throws
|
||||
* (e.g. a detached or cross-origin element).
|
||||
*
|
||||
* @default console.error
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export type UseScrollTarget = MaybeRefOrGetter<HTMLElement | SVGElement | Window | Document | null | undefined>;
|
||||
|
||||
export interface UseScrollEdgeState {
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
top: boolean;
|
||||
bottom: boolean;
|
||||
}
|
||||
|
||||
export interface UseScrollReturn {
|
||||
x: Ref<number>;
|
||||
y: Ref<number>;
|
||||
isScrolling: Ref<boolean>;
|
||||
arrivedState: UseScrollEdgeState;
|
||||
directions: UseScrollEdgeState;
|
||||
/**
|
||||
* Recompute `x`, `y`, `arrivedState`, and `directions` from the current DOM
|
||||
* state. Call after a programmatic layout change that did not emit a scroll
|
||||
* event.
|
||||
*/
|
||||
measure: () => void;
|
||||
}
|
||||
|
||||
const ARRIVED_STATE_THRESHOLD_PIXELS = 1;
|
||||
|
||||
interface ScrollMetrics {
|
||||
scrollLeft: number;
|
||||
scrollTop: number;
|
||||
scrollWidth: number;
|
||||
scrollHeight: number;
|
||||
clientWidth: number;
|
||||
clientHeight: number;
|
||||
/**
|
||||
* `-1` when the element is laid out right-to-left, `1` otherwise. Used to
|
||||
* normalise the (possibly negative) `scrollLeft` reported under RTL.
|
||||
*/
|
||||
directionMultiplier: number;
|
||||
}
|
||||
|
||||
function isWindow(value: unknown, window: Window | undefined): value is Window {
|
||||
return value === window || (typeof Window !== 'undefined' && value instanceof Window);
|
||||
}
|
||||
|
||||
function getScrollMetrics(
|
||||
el: HTMLElement | SVGElement | Window | Document,
|
||||
window: Window,
|
||||
): ScrollMetrics {
|
||||
if (isWindow(el, window)) {
|
||||
const doc = window.document.documentElement;
|
||||
return {
|
||||
scrollLeft: window.scrollX,
|
||||
scrollTop: window.scrollY,
|
||||
scrollWidth: doc.scrollWidth,
|
||||
scrollHeight: doc.scrollHeight,
|
||||
clientWidth: window.innerWidth,
|
||||
clientHeight: window.innerHeight,
|
||||
directionMultiplier: getDirectionMultiplier(doc, window),
|
||||
};
|
||||
}
|
||||
|
||||
const node = (el instanceof Document ? el.documentElement : el) as HTMLElement;
|
||||
|
||||
return {
|
||||
scrollLeft: node.scrollLeft,
|
||||
scrollTop: node.scrollTop,
|
||||
scrollWidth: node.scrollWidth,
|
||||
scrollHeight: node.scrollHeight,
|
||||
clientWidth: node.clientWidth,
|
||||
clientHeight: node.clientHeight,
|
||||
directionMultiplier: getDirectionMultiplier(node, window),
|
||||
};
|
||||
}
|
||||
|
||||
function getDirectionMultiplier(node: Element, window: Window): number {
|
||||
// getComputedStyle can throw on detached nodes; callers wrap this in try/catch.
|
||||
return window.getComputedStyle(node).direction === 'rtl' ? -1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useScroll
|
||||
* @category Browser
|
||||
* @description Reactive scroll position and state for an element or the window,
|
||||
* with arrived-edge detection (RTL-aware), scroll directions, an `isScrolling`
|
||||
* flag, optional throttling, and a `measure()` method for manual re-sync.
|
||||
*
|
||||
* @param {UseScrollTarget} target The scroll container (can be reactive)
|
||||
* @param {UseScrollOptions} [options={}] Options
|
||||
* @returns {UseScrollReturn} Reactive position, scroll state, arrived edges, directions, and `measure`
|
||||
*
|
||||
* @example
|
||||
* const { x, y, isScrolling, arrivedState, measure } = useScroll(el);
|
||||
*
|
||||
* @since 0.0.15
|
||||
*/
|
||||
export function useScroll(
|
||||
target: UseScrollTarget,
|
||||
options: UseScrollOptions = {},
|
||||
): UseScrollReturn {
|
||||
const {
|
||||
throttle = 0,
|
||||
idle = 200,
|
||||
onStop = noop,
|
||||
onScroll = noop,
|
||||
offset = {},
|
||||
eventListenerOptions = { capture: false, passive: true },
|
||||
behavior = 'auto',
|
||||
window = defaultWindow,
|
||||
observe: observeOption = false,
|
||||
onError = noop,
|
||||
} = options;
|
||||
|
||||
const internalX = shallowRef(0);
|
||||
const internalY = shallowRef(0);
|
||||
|
||||
const isScrolling = shallowRef(false);
|
||||
const arrivedState = reactive<UseScrollEdgeState>({ left: true, right: false, top: true, bottom: false });
|
||||
const directions = reactive<UseScrollEdgeState>({ left: false, right: false, top: false, bottom: false });
|
||||
|
||||
const scrollTo = (axis: 'x' | 'y', value: number): void => {
|
||||
const el = toValue(target);
|
||||
|
||||
if (!el)
|
||||
return;
|
||||
|
||||
(el instanceof Document ? el.documentElement : el as HTMLElement | Window).scrollTo(
|
||||
axis === 'x' ? { left: value, behavior } : { top: value, behavior },
|
||||
);
|
||||
};
|
||||
|
||||
const x = computed<number>({
|
||||
get: () => internalX.value,
|
||||
set: value => scrollTo('x', value),
|
||||
});
|
||||
|
||||
const y = computed<number>({
|
||||
get: () => internalY.value,
|
||||
set: value => scrollTo('y', value),
|
||||
});
|
||||
|
||||
const setArrivedState = (m: ScrollMetrics): void => {
|
||||
// RTL elements report a negative scrollLeft; normalise to a magnitude so
|
||||
// edge maths is identical to the LTR case.
|
||||
const left = Math.abs(m.scrollLeft);
|
||||
const top = Math.abs(m.scrollTop);
|
||||
|
||||
arrivedState.left = left <= (offset.left ?? 0);
|
||||
arrivedState.right = left + m.clientWidth >= m.scrollWidth - (offset.right ?? 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
|
||||
arrivedState.top = top <= (offset.top ?? 0);
|
||||
arrivedState.bottom = top + m.clientHeight >= m.scrollHeight - (offset.bottom ?? 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
|
||||
};
|
||||
|
||||
// `trackDirections` only applies when driven by a real scroll event; a manual
|
||||
// measure() should not invent directions, so it is skipped there.
|
||||
const sync = (trackDirections: boolean): void => {
|
||||
const el = toValue(target);
|
||||
|
||||
if (!el || !window)
|
||||
return;
|
||||
|
||||
let m: ScrollMetrics;
|
||||
try {
|
||||
m = getScrollMetrics(el, window);
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const left = m.scrollLeft;
|
||||
const top = m.scrollTop;
|
||||
|
||||
if (trackDirections) {
|
||||
directions.left = left < internalX.value;
|
||||
directions.right = left > internalX.value;
|
||||
directions.top = top < internalY.value;
|
||||
directions.bottom = top > internalY.value;
|
||||
}
|
||||
|
||||
setArrivedState(m);
|
||||
|
||||
internalX.value = left;
|
||||
internalY.value = top;
|
||||
};
|
||||
|
||||
const measure = (): void => sync(false);
|
||||
|
||||
const onScrollEnd = useDebounceFn((event: Event) => {
|
||||
// Guard against the debounce trailing edge firing after we already settled.
|
||||
if (!isScrolling.value)
|
||||
return;
|
||||
|
||||
isScrolling.value = false;
|
||||
directions.left = false;
|
||||
directions.right = false;
|
||||
directions.top = false;
|
||||
directions.bottom = false;
|
||||
onStop(event);
|
||||
}, throttle + idle);
|
||||
|
||||
const onScrollHandler = (event: Event): void => {
|
||||
if (!toValue(target) || !window)
|
||||
return;
|
||||
|
||||
sync(true);
|
||||
|
||||
isScrolling.value = true;
|
||||
onScrollEnd(event);
|
||||
onScroll(event);
|
||||
};
|
||||
|
||||
const handler = throttle > 0
|
||||
? useThrottleFn(onScrollHandler, throttle, true, true)
|
||||
: onScrollHandler;
|
||||
|
||||
useEventListener(
|
||||
target as MaybeRefOrGetter<EventTarget | null | undefined>,
|
||||
'scroll',
|
||||
handler as (event: Event) => void,
|
||||
eventListenerOptions,
|
||||
);
|
||||
|
||||
// Initial measure once a target is resolvable so x/y/arrivedState reflect the
|
||||
// real starting position instead of the optimistic top-left defaults.
|
||||
measure();
|
||||
|
||||
const observe = observeOption === true ? { mutation: true } : observeOption;
|
||||
|
||||
if (observe && observe.mutation) {
|
||||
useMutationObserver(
|
||||
// Window/Document are not observable elements; only observe real elements.
|
||||
() => {
|
||||
const el = toValue(target);
|
||||
return el && !isWindow(el, window) && !(el instanceof Document) ? el : null;
|
||||
},
|
||||
() => measure(),
|
||||
{ window, attributes: true, childList: true, subtree: true },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
isScrolling,
|
||||
arrivedState,
|
||||
directions,
|
||||
measure,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user