react-router-hash-link
Version:
Hash link scroll functionality for React Router v4/5
148 lines (131 loc) • 4.74 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import { Link, NavLink } from 'react-router-dom';
let hashFragment = '';
let observer = null;
let asyncTimerId = null;
let scrollFunction = null;
function reset() {
hashFragment = '';
if (observer !== null) observer.disconnect();
if (asyncTimerId !== null) {
window.clearTimeout(asyncTimerId);
asyncTimerId = null;
}
}
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'))
);
}
function getElAndScroll() {
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) element.setAttribute('tabindex', -1);
element.focus({ preventScroll: true });
if (originalTabIndex === null) {
if (!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;
}
function hashLinkScroll(timeout) {
// Push onto callback queue so it runs after the DOM is updated
window.setTimeout(() => {
if (getElAndScroll() === false) {
if (observer === null) {
observer = new MutationObserver(getElAndScroll);
}
observer.observe(document, {
attributes: true,
childList: true,
subtree: true,
});
// if the element doesn't show up in specified timeout or 10 seconds, stop checking
asyncTimerId = window.setTimeout(() => {
reset();
}, timeout || 10000);
}
}, 0);
}
export function genericHashLink(As) {
return React.forwardRef((props, ref) => {
let linkHash = '';
if (typeof props.to === 'string' && props.to.includes('#')) {
linkHash = `#${props.to.split('#').slice(1).join('#')}`;
} else if (
typeof props.to === 'object' &&
typeof props.to.hash === 'string'
) {
linkHash = props.to.hash;
}
const passDownProps = {};
if (As === NavLink) {
passDownProps.isActive = (match, location) =>
match && match.isExact && location.hash === linkHash;
}
function handleClick(e) {
reset();
hashFragment = props.elementId ? `#${props.elementId}` : linkHash;
if (props.onClick) props.onClick(e);
if (hashFragment !== '') {
scrollFunction =
props.scroll ||
((el) =>
props.smooth
? el.scrollIntoView({ behavior: 'smooth' })
: el.scrollIntoView());
hashLinkScroll(props.timeout);
}
}
const { scroll, smooth, timeout, elementId, ...filteredProps } = props;
return (
<As {...passDownProps} {...filteredProps} onClick={handleClick} ref={ref}>
{props.children}
</As>
);
});
}
export const HashLink = genericHashLink(Link);
export const NavHashLink = genericHashLink(NavLink);
const propTypes = {
onClick: PropTypes.func,
children: PropTypes.node,
scroll: PropTypes.func,
timeout: PropTypes.number,
elementId: PropTypes.string,
to: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
};
HashLink.propTypes = propTypes;
NavHashLink.propTypes = propTypes;