Spaces:
Build error
Build error
import type { MaybeGetter } from "$lib/types.js"; | |
import { AnimationFrames, useDebounce, useEventListener } from "runed"; | |
import { onMount } from "svelte"; | |
import { extract } from "./extract.svelte.js"; | |
import { noop } from "$lib/utils/noop.js"; | |
export interface ScrollStateOptions { | |
/** | |
* The target element. | |
*/ | |
element: MaybeGetter<HTMLElement | Window | Document | null | undefined>; | |
// /** | |
// * Throttle time for scroll event, it’s disabled by default. | |
// * | |
// * @default 0 | |
// */ | |
// throttle?: MaybeGetter<number | undefined>; | |
/** | |
* The check time when scrolling ends. | |
* This configuration will be setting to (throttle + idle) when the `throttle` is configured. | |
* | |
* @default 200 | |
*/ | |
idle?: MaybeGetter<number | undefined>; | |
/** | |
* Offset arrived states by x pixels | |
* | |
*/ | |
offset?: MaybeGetter< | |
| { | |
left?: number; | |
right?: number; | |
top?: number; | |
bottom?: number; | |
} | |
| undefined | |
>; | |
/** | |
* Trigger it when scrolling. | |
* | |
*/ | |
onScroll?: (e: Event) => void; | |
/** | |
* Trigger it when scrolling ends. | |
* | |
*/ | |
onStop?: (e: Event) => void; | |
/** | |
* Listener options for scroll event. | |
* | |
* @default {capture: false, passive: true} | |
*/ | |
eventListenerOptions?: AddEventListenerOptions; | |
/** | |
* Optionally specify a scroll behavior of `auto` (default, not smooth scrolling) or | |
* `smooth` (for smooth scrolling) which takes effect when changing the `x` or `y` refs. | |
* | |
* @default 'auto' | |
*/ | |
behavior?: MaybeGetter<ScrollBehavior | undefined>; | |
/** | |
* On error callback | |
* | |
* Default log error to `console.error` | |
*/ | |
onError?: (error: unknown) => void; | |
} | |
/** | |
* 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; | |
/** | |
* Reactive scroll. | |
* | |
* @see https://vueuse.org/useScroll for the inspiration behind this utility. | |
* @param element | |
* @param options | |
*/ | |
export class ScrollState { | |
#options!: ScrollStateOptions; | |
element = $derived(extract(this.#options.element)); | |
// throttle = $derived(extract(this.#options.throttle, 0)); | |
idle = $derived(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: unknown) => { | |
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, | |
}); | |
constructor(options: ScrollStateOptions) { | |
this.#options = options; | |
useEventListener( | |
() => this.element, | |
"scroll", | |
// throttle ? useThrottleFn(onScrollHandler, throttle, true, false) : onScrollHandler, | |
this.onScrollHandler, | |
this.eventListenerOptions | |
); | |
useEventListener( | |
() => this.element, | |
"scrollend", | |
e => this.onScrollEnd(e), | |
this.eventListenerOptions | |
); | |
onMount(() => { | |
this.setArrivedState(); | |
}); | |
// useResizeObserver( | |
// () => (isHtmlElement(this.element) ? this.element : null), | |
// () => { | |
// setTimeout(() => { | |
// this.setArrivedState(); | |
// }, 100); | |
// } | |
// ); | |
// overkill? | |
new AnimationFrames(() => this.setArrivedState()); | |
} | |
setArrivedState = () => { | |
if (!window || !this.element) return; | |
const el: Element = ((this.element as Window)?.document?.documentElement || | |
(this.element as Document)?.documentElement || | |
(this.element as HTMLElement | SVGElement)) as Element; | |
const { display, flexDirection, direction } = getComputedStyle(el); | |
const directionMultipler = direction === "rtl" ? -1 : 1; | |
const scrollLeft = el.scrollLeft; | |
this.directions.left = scrollLeft < this.internalX; | |
this.directions.right = scrollLeft > this.internalX; | |
const left = scrollLeft * directionMultipler <= (this.offset.left || 0); | |
const right = | |
scrollLeft * directionMultipler + 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; | |
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; | |
} | |
this.internalY = scrollTop; | |
}; | |
onScrollHandler = (e: Event) => { | |
if (!window) return; | |
this.setArrivedState(); | |
this.isScrolling = true; | |
this.onScrollEndDebounced(e); | |
this.onScroll(e); | |
}; | |
scrollTo(x: number | undefined, y: number | undefined) { | |
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 as Window)?.document?.documentElement || | |
(this.element as Document)?.documentElement || | |
(this.element as Element); | |
if (x != null) this.internalX = scrollContainer.scrollLeft; | |
if (y != null) this.internalY = scrollContainer.scrollTop; | |
} | |
scrollToTop() { | |
this.scrollTo(undefined, 0); | |
} | |
scrollToBottom() { | |
if (!window) return; | |
const scrollContainer = | |
(this.element as Window)?.document?.documentElement || | |
(this.element as Document)?.documentElement || | |
(this.element as Element); | |
this.scrollTo(undefined, scrollContainer.scrollHeight); | |
} | |
onScrollEnd = (e: Event) => { | |
// 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); | |
} | |