react-scroll-agent
Version:
Deadly spy for make benefit glorious nation of Scrolltopia
160 lines (138 loc) • 4.11 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import observeRect from '@reach/observe-rect';
// Settings
const REFIRE = 500; // Time in ms to refire calc (takes care of reflow that happens after final animationFrame firing)
// Values
const TOP = 'top';
const CENTER = 'center';
const BOTTOM = 'bottom';
class ScrollAgent extends React.PureComponent {
// Memoized container height, to prevent unnecessary recalcs
_lastH = -1;
// setTimeout placeholder
_lastRecalc = undefined;
// Ref for scrollspy
wrapper = React.createRef();
// Reference of observed element
observer = undefined;
state = {
current: -1,
positions: [],
};
componentDidMount() {
this.observe();
// Initialize (observer won’t fire on mount)
this.handleRecalc();
this.handleScroll(window.scrollY);
}
componentDidUpdate() {
this.observe();
}
componentWillUnmount() {
if (this.observer) this.observer.unobserve();
}
get threshold() {
switch (this.props.threshold) {
case CENTER:
return Math.floor(window.innerHeight / 2);
case BOTTOM:
return window.innerHeight;
case TOP:
default:
return 0;
}
}
// Fires on window scroll, and reflow (images loading, resize, etc.)
observe = () => {
if (this.observer || !this.wrapper.current) return;
this.observer = observeRect(this.wrapper.current, this.handleChange);
this.observer.observe();
};
// Fires on every observation change. Determines what should update.
handleChange = ({ top, height }) => {
if (typeof window === 'undefined') return;
this.handleScroll(top);
if (!this.wrapper.current) return;
if (height > 0 && height !== this._lastH) {
this.handleRecalc();
this._lastH = height;
// After last recalculation, wait 500ms and re-fire.
// This fixes calc issues on longer pages when animationFrame skips.
clearTimeout(this._lastRecalc);
this._lastRecalc = setTimeout(() => this.handleRecalc(), REFIRE);
}
};
// Handle height recalculation
handleRecalc = () => {
let elements = [];
try {
elements = this.wrapper.current.querySelectorAll(this.props.selector);
} catch (err) {
console.error(
`⚠️ ReactScrollAgent: failed prop \`selector="${
this.props.selector
}"\`. Must be a valid param for document.querySelectorAll(): https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll`
);
console.error(err);
}
this.setState({
positions: [...elements]
.map(node => node.getBoundingClientRect().top + window.scrollY)
.sort((a, b) => a - b),
});
};
// Handle scroll event
handleScroll = top => {
// If detectEnd, highlight last item even if it doesn’t reach the top.
if (
this.props.detectEnd &&
Math.floor(this._lastH - window.scrollY - window.innerHeight) <= 1
) {
this.setState(({ positions }) => ({ current: positions.length - 1 }));
return;
}
// Find first section that is “too far,” then step back one.
// Infinity is added at the end so you can step back to the last index.
this.setState(({ positions }) => ({
current:
[...positions, Infinity].findIndex(
y => y - window.scrollY - this.threshold > 0
) - 1,
}));
this._lastY = top;
};
render() {
const {
children,
detectEnd,
nav,
selector,
threshold,
...props
} = this.props;
return (
<div {...props}>
{nav({
current: this.state.current,
positions: this.state.positions,
})}
<div ref={this.wrapper}>{children}</div>
</div>
);
}
}
ScrollAgent.propTypes = {
children: PropTypes.node,
detectEnd: PropTypes.bool,
nav: PropTypes.func,
selector: PropTypes.string.isRequired,
threshold: PropTypes.oneOf([TOP, CENTER, BOTTOM]),
};
ScrollAgent.defaultProps = {
children: undefined,
detectEnd: true,
nav: () => null,
threshold: TOP,
};
export default ScrollAgent;