Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide a "useScrollNavigate" hook #93

Open
firmart opened this issue Dec 20, 2021 · 1 comment
Open

Provide a "useScrollNavigate" hook #93

firmart opened this issue Dec 20, 2021 · 1 comment

Comments

@firmart
Copy link

firmart commented Dec 20, 2021

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
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants