Provide a "useScrollNavigate" hook
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.
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