INIT
This commit is contained in:
+1
@@ -0,0 +1 @@
|
||||
export * from "./scroll-state.svelte.js";
|
||||
+1
@@ -0,0 +1 @@
|
||||
export * from "./scroll-state.svelte.js";
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import type { MaybeGetter } from "../../internal/types.js";
|
||||
export interface ScrollStateOptions {
|
||||
/**
|
||||
* The scroll container.
|
||||
*
|
||||
* Can be an HTMLElement, Window, or Document, or a getter returning one of these.
|
||||
*/
|
||||
element: MaybeGetter<HTMLElement | Window | Document | null | undefined>;
|
||||
/**
|
||||
* Debounce timeout (ms) after scrolling ends.
|
||||
*
|
||||
* @default 200
|
||||
*/
|
||||
idle?: MaybeGetter<number | undefined>;
|
||||
/**
|
||||
* Pixel offset thresholds for determining "arrived" state on each scroll edge.
|
||||
*/
|
||||
offset?: MaybeGetter<{
|
||||
left?: number;
|
||||
right?: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
} | undefined>;
|
||||
/**
|
||||
* Called during scroll events.
|
||||
*/
|
||||
onScroll?: (e: Event) => void;
|
||||
/**
|
||||
* Called after scroll ends (after debounce).
|
||||
*/
|
||||
onStop?: (e: Event) => void;
|
||||
/**
|
||||
* Scroll event listener options.
|
||||
*
|
||||
* @default { capture: false, passive: true }
|
||||
*/
|
||||
eventListenerOptions?: AddEventListenerOptions;
|
||||
/**
|
||||
* Scroll behavior when using `scrollTo`, `scrollToTop`, or `scrollToBottom`,
|
||||
*
|
||||
* @default 'auto'
|
||||
*/
|
||||
behavior?: MaybeGetter<ScrollBehavior | undefined>;
|
||||
/**
|
||||
* Optional error handler.
|
||||
*
|
||||
* @default console.error
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
}
|
||||
/**
|
||||
* A reactive utility for tracking scroll position, direction,
|
||||
* and edge arrival states, while supporting programmatic scrolling.
|
||||
*
|
||||
* @see https://vueuse.org/useScroll for the inspiration behind this utility.
|
||||
* @param element
|
||||
* @param options
|
||||
*/
|
||||
export declare class ScrollState {
|
||||
#private;
|
||||
element: Window | Document | HTMLElement | null | undefined;
|
||||
idle: number;
|
||||
offset: {
|
||||
left?: number;
|
||||
right?: number;
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
};
|
||||
onScroll: (e: Event) => void;
|
||||
onStop: (e: Event) => void;
|
||||
eventListenerOptions: AddEventListenerOptions;
|
||||
behavior: "auto" | "instant" | "smooth";
|
||||
onError: (error: unknown) => void;
|
||||
/** State */
|
||||
internalX: number;
|
||||
internalY: number;
|
||||
get x(): number;
|
||||
set x(v: number);
|
||||
get y(): number;
|
||||
set y(v: number);
|
||||
isScrolling: boolean;
|
||||
arrived: {
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
top: boolean;
|
||||
bottom: boolean;
|
||||
};
|
||||
directions: {
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
top: boolean;
|
||||
bottom: boolean;
|
||||
};
|
||||
progress: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
constructor(options: ScrollStateOptions);
|
||||
/**
|
||||
* Updates direction and edge arrival states based on the current scroll position.
|
||||
* Takes into account writing mode, flex direction, and RTL layouts.
|
||||
*/
|
||||
setArrivedState: () => void;
|
||||
/**
|
||||
* Programmatically scroll to a specific position.
|
||||
*/
|
||||
scrollTo(x: number | undefined, y: number | undefined): void;
|
||||
/**
|
||||
* Scrolls to the top of the element.
|
||||
*/
|
||||
scrollToTop(): void;
|
||||
/**
|
||||
* Scrolls to the bottom of the element.
|
||||
*/
|
||||
scrollToBottom(): void;
|
||||
onScrollEnd: (e: Event) => void;
|
||||
onScrollEndDebounced: ((this: unknown, e: Event) => Promise<void>) & {
|
||||
cancel: () => void;
|
||||
runScheduledNow: () => Promise<void>;
|
||||
pending: boolean;
|
||||
};
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import { AnimationFrames } from "../animation-frames/index.js";
|
||||
import { useDebounce } from "../use-debounce/index.js";
|
||||
import { useEventListener } from "../use-event-listener/index.js";
|
||||
import { onMount } from "svelte";
|
||||
import { extract } from "../extract/extract.svelte.js";
|
||||
import { noop } from "../../internal/utils/function.js";
|
||||
/**
|
||||
* We have to check if the scroll amount is close enough to some threshold in order to
|
||||
* more accurately calculate arrivedState. This is because scrollTop/scrollLeft are non-rounded
|
||||
* numbers, while scrollHeight/scrollWidth and clientHeight/clientWidth are rounded.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
|
||||
*/
|
||||
const ARRIVED_STATE_THRESHOLD_PIXELS = 1;
|
||||
/**
|
||||
* A reactive utility for tracking scroll position, direction,
|
||||
* and edge arrival states, while supporting programmatic scrolling.
|
||||
*
|
||||
* @see https://vueuse.org/useScroll for the inspiration behind this utility.
|
||||
* @param element
|
||||
* @param options
|
||||
*/
|
||||
export class ScrollState {
|
||||
#options;
|
||||
element = $derived(extract(this.#options.element));
|
||||
idle = $derived.by(() => extract(this.#options?.idle, 200));
|
||||
offset = $derived(extract(this.#options.offset, {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}));
|
||||
onScroll = $derived(this.#options.onScroll ?? noop);
|
||||
onStop = $derived(this.#options.onStop ?? noop);
|
||||
eventListenerOptions = $derived(this.#options.eventListenerOptions ?? {
|
||||
capture: false,
|
||||
passive: true,
|
||||
});
|
||||
behavior = $derived(extract(this.#options.behavior, "auto"));
|
||||
onError = $derived(this.#options.onError ??
|
||||
((e) => {
|
||||
console.error(e);
|
||||
}));
|
||||
/** State */
|
||||
internalX = $state(0);
|
||||
internalY = $state(0);
|
||||
// Use a get/set pair for x and y because we want to write the value to the refs
|
||||
// during a `scrollTo()` without firing additional `scrollTo()`s in the process.
|
||||
#x = $derived(this.internalX);
|
||||
get x() {
|
||||
return this.#x;
|
||||
}
|
||||
set x(v) {
|
||||
this.scrollTo(v, undefined);
|
||||
}
|
||||
#y = $derived(this.internalY);
|
||||
get y() {
|
||||
return this.#y;
|
||||
}
|
||||
set y(v) {
|
||||
this.scrollTo(undefined, v);
|
||||
}
|
||||
isScrolling = $state(false);
|
||||
arrived = $state({
|
||||
left: true,
|
||||
right: false,
|
||||
top: true,
|
||||
bottom: false,
|
||||
});
|
||||
directions = $state({
|
||||
left: false,
|
||||
right: false,
|
||||
top: false,
|
||||
bottom: false,
|
||||
});
|
||||
progress = $state({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
constructor(options) {
|
||||
this.#options = options;
|
||||
useEventListener(() => this.element, "scroll", this.#onScrollHandler, this.eventListenerOptions);
|
||||
useEventListener(() => this.element, "scrollend", (e) => this.onScrollEnd(e), this.eventListenerOptions);
|
||||
onMount(() => {
|
||||
this.setArrivedState();
|
||||
});
|
||||
// overkill?
|
||||
new AnimationFrames(() => this.setArrivedState());
|
||||
}
|
||||
/**
|
||||
* Updates direction and edge arrival states based on the current scroll position.
|
||||
* Takes into account writing mode, flex direction, and RTL layouts.
|
||||
*/
|
||||
setArrivedState = () => {
|
||||
if (!window || !this.element)
|
||||
return;
|
||||
const el = (this.element?.document?.documentElement ||
|
||||
this.element?.documentElement ||
|
||||
this.element);
|
||||
const { display, flexDirection, direction } = getComputedStyle(el);
|
||||
const directionMultiplier = direction === "rtl" ? -1 : 1;
|
||||
const scrollLeft = el.scrollLeft;
|
||||
if (scrollLeft !== this.internalX) {
|
||||
this.directions.left = scrollLeft < this.internalX;
|
||||
this.directions.right = scrollLeft > this.internalX;
|
||||
}
|
||||
const left = scrollLeft * directionMultiplier <= (this.offset.left || 0);
|
||||
const right = scrollLeft * directionMultiplier + el.clientWidth >=
|
||||
el.scrollWidth - (this.offset.right || 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
|
||||
if (display === "flex" && flexDirection === "row-reverse") {
|
||||
this.arrived.left = right;
|
||||
this.arrived.right = left;
|
||||
}
|
||||
else {
|
||||
this.arrived.left = left;
|
||||
this.arrived.right = right;
|
||||
}
|
||||
this.internalX = scrollLeft;
|
||||
let scrollTop = el.scrollTop;
|
||||
// patch for mobile compatible
|
||||
if (this.element === window.document && !scrollTop)
|
||||
scrollTop = window.document.body.scrollTop;
|
||||
if (scrollTop !== this.internalY) {
|
||||
this.directions.top = scrollTop < this.internalY;
|
||||
this.directions.bottom = scrollTop > this.internalY;
|
||||
}
|
||||
const top = scrollTop <= (this.offset.top || 0);
|
||||
const bottom = scrollTop + el.clientHeight >=
|
||||
el.scrollHeight - (this.offset.bottom || 0) - ARRIVED_STATE_THRESHOLD_PIXELS;
|
||||
/**
|
||||
* reverse columns and rows behave exactly the other way around,
|
||||
* bottom is treated as top and top is treated as the negative version of bottom
|
||||
*/
|
||||
if (display === "flex" && flexDirection === "column-reverse") {
|
||||
this.arrived.top = bottom;
|
||||
this.arrived.bottom = top;
|
||||
}
|
||||
else {
|
||||
this.arrived.top = top;
|
||||
this.arrived.bottom = bottom;
|
||||
}
|
||||
const height = el.scrollHeight - (this.offset.bottom || 0);
|
||||
this.progress.y = (scrollTop / (height - el.clientHeight)) * 100;
|
||||
const width = el.scrollWidth - (this.offset.left || 0);
|
||||
// Math.abs for rtl support
|
||||
this.progress.x = Math.abs((scrollLeft / (width - el.clientWidth)) * 100);
|
||||
this.internalY = scrollTop;
|
||||
};
|
||||
#onScrollHandler = (e) => {
|
||||
if (!window)
|
||||
return;
|
||||
this.setArrivedState();
|
||||
this.isScrolling = true;
|
||||
this.onScrollEndDebounced(e);
|
||||
this.onScroll(e);
|
||||
};
|
||||
/**
|
||||
* Programmatically scroll to a specific position.
|
||||
*/
|
||||
scrollTo(x, y) {
|
||||
if (!window)
|
||||
return;
|
||||
(this.element instanceof Document ? window.document.body : this.element)?.scrollTo({
|
||||
top: y ?? this.y,
|
||||
left: x ?? this.x,
|
||||
behavior: this.behavior,
|
||||
});
|
||||
const scrollContainer = this.element?.document?.documentElement ||
|
||||
this.element?.documentElement ||
|
||||
this.element;
|
||||
if (x != null)
|
||||
this.internalX = scrollContainer.scrollLeft;
|
||||
if (y != null)
|
||||
this.internalY = scrollContainer.scrollTop;
|
||||
}
|
||||
/**
|
||||
* Scrolls to the top of the element.
|
||||
*/
|
||||
scrollToTop() {
|
||||
this.scrollTo(undefined, 0);
|
||||
}
|
||||
/**
|
||||
* Scrolls to the bottom of the element.
|
||||
*/
|
||||
scrollToBottom() {
|
||||
if (!window)
|
||||
return;
|
||||
const scrollContainer = this.element?.document?.documentElement ||
|
||||
this.element?.documentElement ||
|
||||
this.element;
|
||||
if (!scrollContainer)
|
||||
return;
|
||||
this.scrollTo(undefined, scrollContainer.scrollHeight);
|
||||
}
|
||||
onScrollEnd = (e) => {
|
||||
// dedupe if support native scrollend event
|
||||
if (!this.isScrolling)
|
||||
return;
|
||||
this.isScrolling = false;
|
||||
this.directions.left = false;
|
||||
this.directions.right = false;
|
||||
this.directions.top = false;
|
||||
this.directions.bottom = false;
|
||||
this.onStop(e);
|
||||
};
|
||||
onScrollEndDebounced = useDebounce(this.onScrollEnd, () => this.idle);
|
||||
}
|
||||
Reference in New Issue
Block a user