seitu
Version:
113 lines (112 loc) • 3.59 kB
JavaScript
import { createReadableSubscription, createSubscription } from "../../core/subscription/index.mjs";
//#region src/web/scroll-state/index.ts
const inactive = {
reached: false,
remaining: 0
};
const inactiveState = {
top: inactive,
bottom: inactive,
left: inactive,
right: inactive
};
/**
* Tracks an element's scroll position relative to each edge. Notifies on
* `scroll` and `ResizeObserver` (not inner content growth — call `'~'.notify()`
* after that). The element getter is resolved on first subscribe; recreate the
* handle if the element identity changes (React: `deps`).
*
* @example Vanilla
* ```ts twoslash
* import { createScrollState } from 'seitu/web'
*
* const scroll = createScrollState({
* element: document.querySelector('.container'),
* direction: 'vertical',
* threshold: 10,
* })
*
* scroll.subscribe(state => {
* console.log(state.top.reached)
* console.log(state.top.remaining)
* console.log(state.bottom.reached)
* console.log(state.bottom.remaining)
* })
*
* const state = scroll.get()
* console.log(state)
* ```
*
* @example React (callback ref)
* ```tsx twoslash title="page.tsx"
* 'use client'
*
* import * as React from 'react'
* import { createScrollState } from 'seitu/web'
* import { useSubscription } from 'seitu/react'
*
* function Layout() {
* const [ref, setRef] = React.useState<HTMLDivElement | null>(null)
* const state = useSubscription(() => createScrollState({
* element: ref,
* threshold: 10,
* }), { deps: [ref] })
*
* return (
* <div ref={setRef}>
* {state.top.reached ? 'at the top' : 'scrolled'}
* </div>
* )
* }
* ```
*/
function createScrollState(options) {
const { direction = "both", threshold: rawThreshold = 0 } = options;
const t = typeof rawThreshold === "number" ? {
top: rawThreshold,
bottom: rawThreshold,
left: rawThreshold,
right: rawThreshold
} : {
top: rawThreshold.top ?? 0,
bottom: rawThreshold.bottom ?? 0,
left: rawThreshold.left ?? 0,
right: rawThreshold.right ?? 0
};
const resolveElement = () => typeof options.element === "function" ? options.element() : options.element;
const edge = (reached, remaining) => ({
reached,
remaining: Math.max(0, remaining)
});
const get = () => {
const element = resolveElement();
if (!element) return inactiveState;
const remainingTop = element.scrollTop;
const remainingBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
const remainingLeft = element.scrollLeft;
const remainingRight = element.scrollWidth - element.scrollLeft - element.clientWidth;
const vertical = direction !== "horizontal";
const horizontal = direction !== "vertical";
return {
top: vertical ? edge(remainingTop <= t.top, remainingTop) : inactive,
bottom: vertical ? edge(remainingBottom <= t.bottom, remainingBottom) : inactive,
left: horizontal ? edge(remainingLeft <= t.left, remainingLeft) : inactive,
right: horizontal ? edge(remainingRight <= t.right, remainingRight) : inactive
};
};
const { subscribe, notify } = createSubscription({ onFirstSubscribe() {
const element = resolveElement();
if (!element) return;
const handler = () => notify();
element.addEventListener("scroll", handler, { passive: true });
const observer = typeof ResizeObserver === "undefined" ? void 0 : new ResizeObserver(handler);
observer?.observe(element);
return () => {
element.removeEventListener("scroll", handler);
observer?.disconnect();
};
} });
return createReadableSubscription(get, subscribe, notify, () => inactiveState);
}
//#endregion
export { createScrollState };