@kohlmannj/react-scroll-percentage
Version:
Monitor the scroll percentage of a component inside the viewport, using the IntersectionObserver API.
423 lines (361 loc) • 12.9 kB
JavaScript
;
Object.defineProperty(exports, '__esModule', { value: true });
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
var React = require('react');
var React__default = _interopDefault(React);
var Observer = _interopDefault(require('@kohlmannj/react-intersection-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;