UNPKG

@kohlmannj/react-scroll-percentage

Version:

Monitor the scroll percentage of a component inside the viewport, using the IntersectionObserver API.

426 lines (364 loc) 14 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('react'), require('@kohlmannj/react-intersection-observer')) : typeof define === 'function' && define.amd ? define(['exports', 'react', '@kohlmannj/react-intersection-observer'], factory) : (global = global || self, factory(global.ReactScrollPercentage = {}, global.React, global.Observer)); }(this, function (exports, React, Observer) { 'use strict'; var React__default = 'default' in React ? React['default'] : React; Observer = Observer && Observer.hasOwnProperty('default') ? Observer['default'] : Observer; function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); } function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } function _possibleConstructorReturn(self, call) { if (call && (typeof call === "object" || typeof call === "function")) { return call; } return _assertThisInitialized(self); } /** * Accepts the rootMargin value from the user configuration object * and returns an array of the four margin values as an object containing * the value and unit properties. If any of the values are not properly * formatted or use a unit other than px or %, and error is thrown. * @private * @param {string=} rootMargin An optional rootMargin value, * defaulting to '0px'. * @return {Array<Object>} An array of margin objects with the keys * value and unit. */ function parseRootMargin() { var rootMargin = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '0px'; var margins = rootMargin.split(/\s+/).map(function (margin) { var parts = /^(-?\d*\.?\d+)(px|%)$/.exec(margin); if (!parts) { throw new Error('rootMargin must be specified in pixels or percent'); } return { value: parseFloat(parts[1]), unit: parts[2] }; }); // Handles shorthand. margins[1] = margins[1] || margins[0]; margins[2] = margins[2] || margins[0]; margins[3] = margins[3] || margins[1]; return margins; } var isMonitoring = false; var isScrolling = false; var watchers = new Set(); function onScroll() { if (!isScrolling) { isScrolling = true; requestAnimationFrame(update); } } function update() { isScrolling = false; watchers.forEach(function (cb) { return cb(); }); } function start() { if (!isMonitoring) { window.addEventListener('scroll', onScroll); isMonitoring = true; } } function stop() { if (isMonitoring) { watchers.clear(); window.removeEventListener('scroll', onScroll); isMonitoring = false; } } function watch(cb) { if (!isMonitoring) { start(); } watchers.add(cb); } function unwatch(cb) { watchers.delete(cb); if (!watchers.size) { stop(); } } /** * Monitors scroll, and triggers the children function with updated props * * <ScrollPercentage> * {({inView, percentage}) => ( * <h1>{`${inView} {percentage}`}</h1> * )} * </ScrollPercentage> */ var ScrollPercentageCalculator = /*#__PURE__*/ function (_PureComponent) { _inherits(ScrollPercentageCalculator, _PureComponent); function ScrollPercentageCalculator() { var _getPrototypeOf2; var _this; _classCallCheck(this, ScrollPercentageCalculator); for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } _this = _possibleConstructorReturn(this, (_getPrototypeOf2 = _getPrototypeOf(ScrollPercentageCalculator)).call.apply(_getPrototypeOf2, [this].concat(args))); _defineProperty(_assertThisInitialized(_assertThisInitialized(_this)), "state", { percentage: 0, percentageOfViewport: 0 }); _defineProperty(_assertThisInitialized(_assertThisInitialized(_this)), "handleScroll", function () { var _this$props = _this.props, forwardedRef = _this$props.forwardedRef, rootMargin = _this$props.rootMargin, threshold = _this$props.threshold; if (!forwardedRef || !forwardedRef.current) { return; } var bounds = forwardedRef.current.getBoundingClientRect(); var _ScrollPercentageCalc = ScrollPercentageCalculator.calculatePercentages(bounds, rootMargin, threshold), percentage = _ScrollPercentageCalc.percentage, percentageOfViewport = _ScrollPercentageCalc.percentageOfViewport; if (percentage !== _this.state.percentage) { _this.setState({ percentage: percentage }); } if (percentageOfViewport !== _this.state.percentageOfViewport) { _this.setState({ percentageOfViewport: percentageOfViewport }); } }); return _this; } _createClass(ScrollPercentageCalculator, [{ key: "componentDidMount", value: function componentDidMount() { // Start by updating the scroll position, so it correctly reflects the elements start position this.handleScroll(); if (this.props.inView) { this.monitorScroll(this.props.inView); } } }, { key: "componentDidUpdate", value: function componentDidUpdate(prevProps, // tslint:disable-line variable-name prevState) { if (this.props.onChange && (prevState.percentage !== this.state.percentage || prevProps.inView !== this.props.inView)) { // const transitionedToOffScreenButNeedsPercentageUpdate = // !!prevProps.inView && // !this.props.inView && // this.state.percentage > 0 && // this.state.percentage < 1 // if (transitionedToOffScreenButNeedsPercentageUpdate) { // console.log({ transitionedToOffScreenButNeedsPercentageUpdate }) // } this.props.onChange(this.state.percentage, this.props.inView, this.state.percentageOfViewport); } if (prevProps.inView !== this.props.inView) { this.monitorScroll(this.props.inView); } } }, { key: "componentWillUnmount", value: function componentWillUnmount() { this.monitorScroll(false); } }, { key: "monitorScroll", value: function monitorScroll(enable) { if (enable) { watch(this.handleScroll); } else { // Call handleScroll() an additional time to cover an edge case affecting the call to // this.props.onChange() when we transition from in-view to out-of-view, but // this.state.percentage isn't yet equal to 0 or 1 if (this.state.percentage > 0 && this.state.percentage < 1) { // console.log( // 'Calling handleScroll() an additional time to update this.state.percentage after transitioning from in-view to out-of-view', // ) this.handleScroll(); } unwatch(this.handleScroll); } } }, { key: "render", value: function render() { var _this$props2 = this.props, children = _this$props2.children, inView = _this$props2.inView, forwardedRef = _this$props2.forwardedRef; var _this$state = this.state, percentage = _this$state.percentage, percentageOfViewport = _this$state.percentageOfViewport; return (typeof children === 'function' ? children({ inView: inView, forwardedRef: forwardedRef, percentage: percentage, percentageOfViewport: percentageOfViewport }) : children) || null; } }], [{ key: "viewportHeight", value: function viewportHeight() { return window.parent ? window.parent.innerHeight : window.innerHeight || 0; } }, { key: "getRootMarginOffsetTerm", value: function getRootMarginOffsetTerm(_ref, length) { var value = _ref.value, unit = _ref.unit; switch (unit) { case 'px': return value; case '%': return value / 100 * length; default: throw new Error("'".concat(unit, "' units not supported")); } } }, { key: "calculatePercentages", value: function calculatePercentages(bounds, rootMargin) { var threshold = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; var parsedRootMargin = parseRootMargin(rootMargin); var rootMarginTop = parsedRootMargin[0]; var rootMarginBottom = parsedRootMargin[2]; var vh = ScrollPercentageCalculator.viewportHeight(); var offsetTop = threshold * vh * 0.25 - ScrollPercentageCalculator.getRootMarginOffsetTerm(rootMarginTop, vh); var offsetBottom = threshold * vh * 0.25 - ScrollPercentageCalculator.getRootMarginOffsetTerm(rootMarginBottom, vh); var percentage = 1 - Math.max(0, Math.min(1, (bounds.bottom - offsetTop) / (vh + bounds.height - offsetBottom - offsetTop))); var lowestTopEdge = Math.max(offsetTop, bounds.top); var highestBottomEdge = Math.min(vh - offsetBottom, bounds.bottom); var effectiveViewportHeight = 0 - offsetTop + vh - offsetBottom; var percentageOfViewport = Math.max(0, Math.min(1, (highestBottomEdge - lowestTopEdge) / effectiveViewportHeight)); return { percentage: percentage, percentageOfViewport: percentageOfViewport }; } }]); return ScrollPercentageCalculator; }(React.PureComponent); _defineProperty(ScrollPercentageCalculator, "defaultProps", { threshold: 0 /** * Get the correct viewport height. If rendered inside an iframe, grab it from the parent */ }); // TODO: convert to Stateless Functional Component (SFC) /** @see https://github.com/DefinitelyTyped/DefinitelyTyped/issues/28249 */ var ScrollPercentageObserver = /*#__PURE__*/ function (_PureComponent) { _inherits(ScrollPercentageObserver, _PureComponent); function ScrollPercentageObserver() { _classCallCheck(this, ScrollPercentageObserver); return _possibleConstructorReturn(this, _getPrototypeOf(ScrollPercentageObserver).apply(this, arguments)); } _createClass(ScrollPercentageObserver, [{ key: "render", value: function render() { var _this$props = this.props, children = _this$props.children, onChange = _this$props.onChange, root = _this$props.root, rootId = _this$props.rootId, rootMargin = _this$props.rootMargin, forwardedRefProp = _this$props.forwardedRef, threshold = _this$props.threshold, triggerOnce = _this$props.triggerOnce; return React__default.createElement(Observer, { forwardedRef: forwardedRefProp, root: root, rootId: rootId, rootMargin: rootMargin, threshold: threshold, triggerOnce: triggerOnce }, function (_ref) { var inView = _ref.inView, forwardedRef = _ref.forwardedRef; return React__default.createElement(ScrollPercentageCalculator, { forwardedRef: forwardedRef, inView: inView, onChange: onChange, rootMargin: rootMargin, threshold: threshold }, children); }); } }]); return ScrollPercentageObserver; }(React.PureComponent); _defineProperty(ScrollPercentageObserver, "defaultProps", { forwardedRef: React.createRef(), threshold: 0, triggerOnce: false }); exports.ScrollPercentageCalculator = ScrollPercentageCalculator; exports.ScrollPercentageObserver = ScrollPercentageObserver; Object.defineProperty(exports, '__esModule', { value: true }); }));