-
Notifications
You must be signed in to change notification settings - Fork 62
/
HashLink.jsx
159 lines (143 loc) · 5.37 KB
/
HashLink.jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
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 && !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;
}
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 !== '' &&
// ignore non-vanilla click events, same as react-router
// below logic adapted from react-router: https://github.com/ReactTraining/react-router/blob/fc91700e08df8147bd2bb1be19a299cbb14dbcaa/packages/react-router-dom/modules/Link.js#L43-L48
!e.defaultPrevented && // onClick prevented default
e.button === 0 && // ignore everything but left clicks
(!props.target || props.target === '_self') && // let browser handle "target=_blank" etc
!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) // ignore clicks with modifier keys
) {
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);
if (process.env.NODE_ENV !== 'production') {
HashLink.displayName = 'HashLink';
NavHashLink.displayName = 'NavHashLink';
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;
}