import React, { ReactNode, useRef, useEffect, createContext, useContext, Dispatch, HTMLAttributes, forwardRef, ReactElement, AnchorHTMLAttributes, MouseEvent, ChangeEvent, InputHTMLAttributes, cloneElement, Children, memo, useMemo, useReducer, } from "react"; import mergeRefs from "react-merge-refs"; import classNames from "classnames"; import { bemHOF } from "../../utilities"; import { useIntersection, usePrefersReducedMotion } from "../../hooks"; import { ScrollSpyState, ScrollSpyAction } from "./types"; import { reducer, scrollSpyInitialState } from "./reducers"; import { selectCurrentIndex, selectCurrentPage } from "./selectors"; import { updateIndex } from "./actions"; const cn = bemHOF("ScrollSpy"); const DEFAULT_ID = "scroll-spy-1"; // ------------------------------------------------------------------ // ScrollSpy.StateContext // ------------------------------------------------------------------ const initialContextState = { state: scrollSpyInitialState, dispatch: () => {}, }; export const ScrollSpyStateContext = createContext<{ state: ScrollSpyState; dispatch: Dispatch; }>(initialContextState); export function useScrollSpyStateContext() { const context = useContext(ScrollSpyStateContext) || { ...initialContextState, }; return context; } // ------------------------------------------------------------------ // ScrollSpy.Context // ------------------------------------------------------------------ type ScrollSpyContextType = { id: string; itemCount: number; defaultIndex: number; updateCurrentRef(index: number, refItem: HTMLElement): void; scrollToRef(refToScrollTo: HTMLElement): void; scrollToFirst(event: MouseEvent): void; scrollToLast(event: MouseEvent): void; scrollToPage(page: number): void; skipToPage(page: number): void; }; const defaultRootContext: ScrollSpyContextType = { id: DEFAULT_ID, itemCount: 1, defaultIndex: 0, updateCurrentRef: () => {}, scrollToRef: () => {}, scrollToFirst: () => {}, scrollToLast: () => {}, scrollToPage: () => {}, skipToPage: () => {}, }; export const ScrollSpyContext = createContext( defaultRootContext, ); export function useScrollSpyContext() { const context = useContext(ScrollSpyContext) || { ...defaultRootContext, }; return context; } // ------------------------------------------------------------------ // ScrollSpy.Input // ------------------------------------------------------------------ export type ScrollSpyInputUsePropsHookProps = { className?: string; onChange?(event: ChangeEvent): void; }; export const useScrollSpyInputProps = ({ className, onChange, }: ScrollSpyInputUsePropsHookProps) => { const { dispatch, state } = useScrollSpyStateContext(); const { itemCount, skipToPage } = useScrollSpyContext(); const currentPage = selectCurrentPage(state); const onChangeHandler = (event: ChangeEvent) => { event.preventDefault(); event.stopPropagation(); const pageNumber = parseInt(event.target.value, 10); const index = pageNumber - 1; dispatch(updateIndex(index)); if (pageNumber && pageNumber >= 1 && pageNumber <= itemCount) { skipToPage(pageNumber); } if (onChange) { onChange(event); } }; return { className: classNames(cn({ e: "input" }), className), type: "number", min: "1", max: itemCount, maxLength: itemCount.toString().split("").length, onChange: onChangeHandler, value: currentPage, }; }; export type ScrollSpyInputProps = InputHTMLAttributes; export const ScrollSpyInput = memo( forwardRef( ({ className, ...rest }, forwardedRef) => { const props = useScrollSpyInputProps({ className }); return ; }, ), ); // ------------------------------------------------------------------ // ScrollSpy.Page // ------------------------------------------------------------------ export interface ScrollSpyPageProps extends HTMLAttributes { children: ReactElement; index: number; threshold?: number | number[] | undefined; } export type ScrollSpyPageUsePropsHookProps = { index: number; id?: string; }; export const useScrollSpyPageProps = ( { index, id }: ScrollSpyPageUsePropsHookProps = { index: 0 }, ) => { const { id: rootId } = useScrollSpyContext(); const pageId = `${rootId}-page-${index + 1}`; return { id: id || pageId, }; }; export const ScrollSpyPage = memo( forwardRef( ( { id: idProp, children, index, threshold = 0.5, ...rest }, forwardedRef, ) => { const ref = useRef(null); const combinedRef = mergeRefs([ref, forwardedRef]); const isVisible = useIntersection(ref, { threshold, }); const { dispatch } = useScrollSpyStateContext(); const { updateCurrentRef } = useScrollSpyContext(); const { id } = useScrollSpyPageProps({ index, id: idProp }); useEffect(() => { if (ref && ref.current) { updateCurrentRef(index, ref.current); } }); useEffect(() => { if (isVisible) { dispatch(updateIndex(index)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisible]); return ( <> {cloneElement(Children.only(children), { ...rest, ...children.props, id, ref: combinedRef, })} ); }, ), ); // ------------------------------------------------------------------ // ScrollSpy.Anchor // ------------------------------------------------------------------ export interface ScrollSpyAnchorProps extends AnchorHTMLAttributes { children: ReactNode; index: number; } export type ScrollSpyAnchorUsePropsHookProps = { index: number; id?: string; href?: string; onClick?(event: MouseEvent): void; }; export const useScrollSpyAnchorProps = ( { index, id, href, onClick }: ScrollSpyAnchorUsePropsHookProps = { index: 0 }, ) => { const { id: rootId, scrollToPage } = useScrollSpyContext(); const { id: pageId } = useScrollSpyPageProps({ index }); const anchorId = `${rootId}-anchor-${index + 1}`; const { state } = useScrollSpyStateContext(); const currentIndex = selectCurrentIndex(state); const onClickHandler = (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); scrollToPage(index + 1); if (onClick) { onClick(event); } }; return { id: id || anchorId, href: href || `#${pageId}`, onClick: onClickHandler, isActive: currentIndex === index, }; }; export const ScrollSpyAnchor = memo( forwardRef( ({ id, children, onClick, index = 0, ...rest }, forwardedRef) => { const { isActive, ...anchorProps } = useScrollSpyAnchorProps({ index, id, onClick, }); return ( {children} ); }, ), ); // ------------------------------------------------------------------ // ScrollSpy.Root // ------------------------------------------------------------------ export type ScrollSpyRootProps = { children: ReactNode; itemCount: number; defaultIndex?: number; id?: string; }; export const ScrollSpyRoot = memo( ({ children, itemCount, defaultIndex = 0, id = DEFAULT_ID, }: ScrollSpyRootProps) => { const refList = useRef([]); const prefersReducedMotion = usePrefersReducedMotion(); const scrollBehavior = prefersReducedMotion ? "auto" : "smooth"; useEffect(() => { refList.current = refList.current.slice(0, itemCount); }, [itemCount]); const updateCurrentRef = (index: number, refItem: HTMLElement) => { refList.current[index] = refItem; }; const scrollToRef = ( refToScrollTo: HTMLElement, behavior: "auto" | "smooth" = "smooth", ) => { refToScrollTo.scrollIntoView({ behavior }); }; const scrollToFirst = (event: MouseEvent) => { event.stopPropagation(); scrollToRef(refList.current[0], scrollBehavior); }; const scrollToLast = (event: MouseEvent) => { event.stopPropagation(); scrollToRef(refList.current[itemCount - 1], scrollBehavior); }; const scrollToPage = (page: number) => { const index = page - 1; scrollToRef(refList.current[index], scrollBehavior); }; const skipToPage = (page: number) => { const index = page - 1; scrollToRef(refList.current[index], "auto"); }; useEffect(() => { const lastIndex = itemCount - 1; if (defaultIndex > 0 && defaultIndex <= lastIndex) { skipToPage(defaultIndex + 1); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [defaultIndex, itemCount]); const context = useMemo(() => { return { id, itemCount, defaultIndex, updateCurrentRef, scrollToRef, scrollToFirst, scrollToLast, scrollToPage, skipToPage, }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, itemCount, defaultIndex]); return ( {children} ); }, ); export const ScrollSpyStateProvider = ({ children, }: { children: ReactNode; }) => { const [state, dispatch] = useReducer(reducer, { index: 0, page: 1, }); const value = useMemo(() => ({ state, dispatch }), [state]); return ( {children} ); }; export const ScrollSpyReducers = { reducer }; export const ScrollSpySelectors = { selectCurrentIndex, selectCurrentPage }; export const ScrollSpyActions = { updateIndex }; /** * @TODO get types right and re-enable these exports */ // ------------------------------------------------------------------ // ScrollSpy // ------------------------------------------------------------------ // export type ScrollSpyProps = ScrollSpyRootProps & { // Root: ScrollSpyRootProps; // Anchor: ScrollSpyAnchorProps; // Page: ScrollSpyPageProps; // }; // export const ScrollSpy = () => ({ // ...ScrollSpyRoot, // ...{ // Root: ScrollSpyRoot, // Anchor: ScrollSpyAnchor, // Page: ScrollSpyPage, // }, // });