UNPKG

react-scroll-espionage

Version:
143 lines (119 loc) 3.45 kB
import React, { Component } from 'react'; import PropTypes from 'prop-types'; import observeRect from '@reach/observe-rect'; export default class ScrollEspionage extends Component { static propTypes = { children: PropTypes.node, nav: PropTypes.func, selector: PropTypes.string, threshold: PropTypes.number, // Deprecated detectEnd: PropTypes.bool, }; static defaultProps = { children: undefined, nav: () => null, selector: '[data-scrollspy]', threshold: 0, // Deprecated // TODO: set current value detectEnd: false, }; // Memoized container height, to prevent unnecessary recalcs _lastH = -1; // setTimeout placeholder _lastRecalc = null; // Ref for scrollspy wrapper = React.createRef(); // Reference of observed element observer = null; 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(); } // 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(), 500); } }; // Handle height recalculation handleRecalc = () => { let elements = []; if (this.props.selector) { elements = this.wrapper.current.querySelectorAll(this.props.selector); } 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.props.threshold > 0, ) - 1, })); this._lastY = top; }; render() { const { children, detectEnd, nav, selector, threshold, ...props } = this.props; return ( <div ref={this.wrapper} {...props}> {nav({ current: this.state.current, positions: this.state.positions, })} {children} </div> ); } }