UNPKG

masonic

Version:

<hr> <div align="center"> <h1 align="center"> 🧱 masonic </h1>

172 lines (158 loc) • 5.13 kB
import useEvent from "@react-hook/event"; import useLatest from "@react-hook/latest"; import { useThrottleCallback } from "@react-hook/throttle"; import * as React from "react"; import type { Positioner, PositionerItem } from "./use-positioner"; /** * A hook that creates a callback for scrolling to a specific index in * the "items" array. * * @param positioner - A positioner created by the `usePositioner()` hook * @param options - Configuration options */ export function useScrollToIndex( positioner: Positioner, options: UseScrollToIndexOptions ) { const { align = "top", element = typeof window !== "undefined" && window, offset = 0, height = typeof window !== "undefined" ? window.innerHeight : 0, } = options; const latestOptions = useLatest({ positioner, element, align, offset, height, } as const); const getTarget = React.useRef(() => { const latestElement = latestOptions.current.element; return latestElement && "current" in latestElement ? latestElement.current : latestElement; }).current; const [state, dispatch] = React.useReducer( ( state: { position: PositionerItem | undefined; index: number | undefined; prevTop: number | undefined; }, action: | { type: "scrollToIndex"; value: number | undefined } | { type: "setPosition"; value: PositionerItem | undefined } | { type: "setPrevTop"; value: number | undefined } | { type: "reset" } ) => { const nextState = { position: state.position, index: state.index, prevTop: state.prevTop, }; /* istanbul ignore next */ if (action.type === "scrollToIndex") { return { position: latestOptions.current.positioner.get(action.value ?? -1), index: action.value, prevTop: void 0, }; } else if (action.type === "setPosition") { nextState.position = action.value; } else if (action.type === "setPrevTop") { nextState.prevTop = action.value; } else if (action.type === "reset") { return defaultState; } return nextState; }, defaultState ); const throttledDispatch = useThrottleCallback(dispatch, 15); // If we find the position along the way we can immediately take off // to the correct spot. useEvent(getTarget() as Window, "scroll", () => { if (!state.position && state.index) { const position = latestOptions.current.positioner.get(state.index); if (position) { dispatch({ type: "setPosition", value: position }); } } }); // If the top changes out from under us in the case of dynamic cells, we // want to keep following it. const currentTop = state.index !== void 0 && latestOptions.current.positioner.get(state.index)?.top; React.useEffect(() => { const target = getTarget(); if (!target) return; const { height, align, offset, positioner } = latestOptions.current; if (state.position) { let scrollTop = state.position.top; if (align === "bottom") { scrollTop = scrollTop - height + state.position.height; } else if (align === "center") { scrollTop -= (height - state.position.height) / 2; } target.scrollTo(0, Math.max(0, (scrollTop += offset))); // Resets state after 400ms, an arbitrary time I determined to be // still visually pleasing if there is a slow network reply in dynamic // cells let didUnsubscribe = false; const timeout = setTimeout( () => !didUnsubscribe && dispatch({ type: "reset" }), 400 ); return () => { didUnsubscribe = true; clearTimeout(timeout); }; } else if (state.index !== void 0) { // Estimates the top based upon the average height of current cells let estimatedTop = (positioner.shortestColumn() / positioner.size()) * state.index; if (state.prevTop) estimatedTop = Math.max(estimatedTop, state.prevTop + height); target.scrollTo(0, estimatedTop); throttledDispatch({ type: "setPrevTop", value: estimatedTop }); } }, [currentTop, state, latestOptions, getTarget, throttledDispatch]); return React.useRef((index: number) => { dispatch({ type: "scrollToIndex", value: index }); }).current; } const defaultState = { index: void 0, position: void 0, prevTop: void 0, } as const; export type UseScrollToIndexOptions = { /** * The window element or a React ref for the window element. That is, * this is the grid container. * * @default window */ element?: Window | HTMLElement | React.RefObject<HTMLElement> | null; /** * Sets the vertical alignment of the cell within the grid container. * * @default "top" */ align?: "center" | "top" | "bottom"; /** * The height of the grid. * * @default window.innerHeight */ height?: number; /** * The vertical space in pixels between the top of the grid container and the top * of the window. * * @default 0 */ offset?: number; };