react-router-hash-link
react-router-hash-link copied to clipboard
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