rafgraph / react-router-hash-link

Hash link scroll functionality for React Router
https://react-router-hash-link.rafgraph.dev
MIT License
732 stars 62 forks source link

Provide a "useScrollNavigate" hook #93

Open firmart opened 2 years ago

firmart commented 2 years ago

react-router-hash-link's components work perfectly, however I have a situation in which I need to navigate (using useNavigate from react-router v6) programmatically: the scroll feature is thus gone. It's actually pretty easy to wrap react-router-hash-link codebase into a hook, here is the working snippet:

import { useCallback, useState, useMemo } from 'react';
import { useNavigate } from "react-router";

function isInteractiveElement(element) {
    const formTags = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'];
    const linkTags = ['A', 'AREA'];
    return (
        (formTags.includes(element.tagName) && !element.hasAttribute('disabled')) ||
        (linkTags.includes(element.tagName) && element.hasAttribute('href'))
    );
}

const useScrollNavigate = (props = {}) => {
    const navigate = useNavigate();
    const observerRef = useRef(null);
    const asyncTimerIdRef = useRef(null);

    const scrollFunction = useMemo(() => {
        return (
            props.scroll || (el => props.smooth
                ? el.scrollIntoView({ behavior: 'smooth' })
                : el.scrollIntoView())
        );
    }, [props.scroll, props.smooth]);

    const reset = useCallback(() => {
        if (observerRef && observerRef.current !== null) {
            observerRef.current.disconnect();
            observerRef.current = null;
        }
        if (asyncTimerIdRef && asyncTimerIdRef.current !== null) {
            window.clearTimeout(asyncTimerIdRef.current);
            asyncTimerIdRef.current = null;
        }
    }, []);

    const getElAndScroll = useCallback((hashFragment) => {
        let element = null;
        if (hashFragment === '#') {
            // use document.body instead of document.documentElement because of a bug in smoothscroll-polyfill in safari
            // see https://github.com/iamdustan/smoothscroll/issues/138
            // while smoothscroll-polyfill is not included, it is the recommended way to implement smoothscroll
            // in browsers that don't natively support el.scrollIntoView({ behavior: 'smooth' })
            element = document.body;
        } else {
            // check for element with matching id before assume '#top' is the top of the document
            // see https://html.spec.whatwg.org/multipage/browsing-the-web.html#target-element
            const id = hashFragment.replace('#', '');
            element = document.getElementById(id);
            if (element === null && hashFragment === '#top') {
                // see above comment for why document.body instead of document.documentElement
                element = document.body;
            }
        }

        if (element !== null) {
            scrollFunction(element);

            // update focus to where the page is scrolled to
            // unfortunately this doesn't work in safari (desktop and iOS) when blur() is called
            let originalTabIndex = element.getAttribute('tabindex');
            if (originalTabIndex === null && !isInteractiveElement(element)) {
                element.setAttribute('tabindex', -1);
            }
            element.focus({ preventScroll: true });
            if (originalTabIndex === null && !isInteractiveElement(element)) {
                // for some reason calling blur() in safari resets the focus region to where it was previously,
                // if blur() is not called it works in safari, but then are stuck with default focus styles
                // on an element that otherwise might never had focus styles applied, so not an option
                element.blur();
                element.removeAttribute('tabindex');
            }

            reset();
            return true;
        }
        return false;
    }, [reset, scrollFunction]);

    const hashLinkScroll = useCallback((hashFragment) => {
        // Push onto callback queue so it runs after the DOM is updated
        window.setTimeout(() => {
            if (getElAndScroll(hashFragment) === false) {
                if (observerRef.current === null) {
                    observerRef.current = new MutationObserver(() => getElAndScroll(hashFragment));
                }
                observerRef.current.observe(document, {
                    attributes: true,
                    childList: true,
                    subtree: true,
                });
                // if the element doesn't show up in specified timeout or 10 seconds, stop checking
                const asyncTimerId = window.setTimeout(() => {
                    reset();
                }, 10000);
                asyncTimerIdRef.current = asyncTimerId;
            }
        }, 0);
    }, [getElAndScroll, reset]);

    const scrollNavigate = useCallback((path, options) => {

        reset();
        const match = path.match(/^.*?(#.*)$/);
        const hash = match ? match[1] : null;
        navigate(path, options);
        if (hash) {
            hashLinkScroll(hash);
        }
    }, [hashLinkScroll, navigate, reset]);

    return scrollNavigate;
};

export default useScrollNavigate;

Basically, everything remains the same, except some variables are passed to functions. The function returned by the hook useScrollNavigate is used exactly in the same way as navigate returned by useNavigate. useScrollNavigate accepts the same extra options of the HashLink component: {smooth: Boolean, scroll: Function}. The components can then directly use this hook to reuse the business logic. Didn't make a PR as I don't have much time and not quite sure if these changes make sense at all.

Vinorcola commented 2 years ago

I'd like to suggest another solution that looks much cleaner to me: a component that watch for url hash change and handle scroll on render, rather that on click. It doesn't require that package and works with useNavigate out-of-the-box.

https://gist.github.com/Vinorcola/93f8431bb190895f5de423db25f3890f