UNPKG

seitu

Version:
113 lines (112 loc) • 3.59 kB
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 };