react-stickynode
Version:
A performant and comprehensive React sticky component
448 lines (435 loc) • 15.7 kB
JavaScript
/**
* Copyright 2015, Yahoo! Inc.
* Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms.
*/
'use strict';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { subscribe } from 'subscribe-ui-event';
import classNames from 'classnames';
import shallowEqual from 'shallowequal';
// constants
const STATUS_ORIGINAL = 0; // The default status, locating at the original position.
const STATUS_RELEASED = 1; // The released status, locating at somewhere on document but not default one.
const STATUS_FIXED = 2; // The sticky status, locating fixed to the top or the bottom of screen.
let TRANSFORM_PROP = 'transform';
// global variable for all instances
let doc;
let docBody;
let docEl;
let canEnableTransforms = true; // Use transform by default, so no Sticky on lower-end browser when no Modernizr
let M;
let scrollDelta = 0;
let win;
let winHeight = -1;
class Sticky extends Component {
constructor(props, context) {
super(props, context);
this.handleResize = this.handleResize.bind(this);
this.handleScroll = this.handleScroll.bind(this);
this.handleScrollStart = this.handleScrollStart.bind(this);
this.delta = 0;
this.stickyTop = 0;
this.stickyBottom = 0;
this.frozen = false;
this.skipNextScrollEvent = false;
this.scrollTop = -1;
this.bottomBoundaryTarget;
this.topTarget;
this.subscribers;
this.state = {
top: 0,
// A top offset from viewport top where Sticky sticks to when scrolling up
bottom: 0,
// A bottom offset from viewport top where Sticky sticks to when scrolling down
width: 0,
// Sticky width
height: 0,
// Sticky height
x: 0,
// The original x of Sticky
y: 0,
// The original y of Sticky
topBoundary: 0,
// The top boundary on document
bottomBoundary: Infinity,
// The bottom boundary on document
status: STATUS_ORIGINAL,
// The Sticky status
pos: 0,
// Real y-axis offset for rendering position-fixed and position-relative
activated: false // once browser info is available after mounted, it becomes true to avoid checksum error
};
}
getTargetHeight(target) {
return target && target.offsetHeight || 0;
}
getTopPosition(top) {
// a top argument can be provided to override reading from the props
top = top || this.props.top || 0;
if (typeof top === 'string') {
if (!this.topTarget) {
this.topTarget = doc.querySelector(top);
}
top = this.getTargetHeight(this.topTarget);
}
return top;
}
getTargetBottom(target) {
if (!target) {
return -1;
}
const rect = target.getBoundingClientRect();
return this.scrollTop + rect.bottom;
}
getBottomBoundary(bottomBoundary) {
// a bottomBoundary can be provided to avoid reading from the props
let boundary = bottomBoundary || this.props.bottomBoundary;
// TODO, bottomBoundary was an object, depricate it later.
if (typeof boundary === 'object') {
boundary = boundary.value || boundary.target || 0;
}
if (typeof boundary === 'string') {
if (!this.bottomBoundaryTarget) {
this.bottomBoundaryTarget = doc.querySelector(boundary);
}
boundary = this.getTargetBottom(this.bottomBoundaryTarget);
}
return boundary && boundary > 0 ? boundary : Infinity;
}
reset() {
this.setState({
status: STATUS_ORIGINAL,
pos: 0
});
}
release(pos) {
this.setState({
status: STATUS_RELEASED,
pos: pos - this.state.y
});
}
fix(pos) {
this.setState({
status: STATUS_FIXED,
pos: pos
});
}
/**
* Update the initial position, width, and height. It should update whenever children change.
* @param {Object} options optional top and bottomBoundary new values
*/
updateInitialDimension(options) {
options = options || {};
if (!this.outerElement || !this.innerElement) {
return;
}
const outerRect = this.outerElement.getBoundingClientRect();
const innerRect = this.innerElement.getBoundingClientRect();
const width = outerRect.width || outerRect.right - outerRect.left;
const height = innerRect.height || innerRect.bottom - innerRect.top;
const outerY = outerRect.top + this.scrollTop;
this.setState({
top: this.getTopPosition(options.top),
bottom: Math.min(this.state.top + height, winHeight),
width,
height,
x: outerRect.left,
y: outerY,
bottomBoundary: this.getBottomBoundary(options.bottomBoundary),
topBoundary: outerY
});
}
handleResize(e, ae) {
if (this.props.shouldFreeze()) {
return;
}
winHeight = ae.resize.height;
this.updateInitialDimension();
this.update();
}
handleScrollStart(e, ae) {
this.frozen = this.props.shouldFreeze();
if (this.frozen) {
return;
}
if (this.scrollTop === ae.scroll.top) {
// Scroll position hasn't changed,
// do nothing
this.skipNextScrollEvent = true;
} else {
this.scrollTop = ae.scroll.top;
this.updateInitialDimension();
}
}
handleScroll(e, ae) {
// Scroll doesn't need to be handled
if (this.skipNextScrollEvent) {
this.skipNextScrollEvent = false;
return;
}
scrollDelta = ae.scroll.delta;
this.scrollTop = ae.scroll.top;
this.update();
}
/**
* Update Sticky position.
*/
update() {
var disabled = !this.props.enabled || this.state.bottomBoundary - this.state.topBoundary <= this.state.height || this.state.width === 0 && this.state.height === 0;
if (disabled) {
if (this.state.status !== STATUS_ORIGINAL) {
this.reset();
}
return;
}
var delta = scrollDelta;
// "top" and "bottom" are the positions that this.state.top and this.state.bottom project
// on document from viewport.
var top = this.scrollTop + this.state.top;
var bottom = this.scrollTop + this.state.bottom;
// There are 2 principles to make sure Sticky won't get wrong so much:
// 1. Reset Sticky to the original postion when "top" <= topBoundary
// 2. Release Sticky to the bottom boundary when "bottom" >= bottomBoundary
if (top <= this.state.topBoundary) {
// #1
this.reset();
} else if (bottom >= this.state.bottomBoundary) {
// #2
this.stickyBottom = this.state.bottomBoundary;
this.stickyTop = this.stickyBottom - this.state.height;
this.release(this.stickyTop);
} else {
if (this.state.height > winHeight - this.state.top) {
// In this case, Sticky is higher then viewport minus top offset
switch (this.state.status) {
case STATUS_ORIGINAL:
this.release(this.state.y);
this.stickyTop = this.state.y;
this.stickyBottom = this.stickyTop + this.state.height;
// Commentting out "break" is on purpose, because there is a chance to transit to FIXED
// from ORIGINAL when calling window.scrollTo().
// break;
/* eslint-disable-next-line no-fallthrough */
case STATUS_RELEASED:
// If "top" and "bottom" are inbetween stickyTop and stickyBottom, then Sticky is in
// RELEASE status. Otherwise, it changes to FIXED status, and its bottom sticks to
// viewport bottom when scrolling down, or its top sticks to viewport top when scrolling up.
this.stickyBottom = this.stickyTop + this.state.height;
if (delta > 0 && bottom > this.stickyBottom) {
this.fix(this.state.bottom - this.state.height);
} else if (delta < 0 && top < this.stickyTop) {
this.fix(this.state.top);
}
break;
case STATUS_FIXED:
var toRelease = true;
var pos = this.state.pos;
var height = this.state.height;
// In regular cases, when Sticky is in FIXED status,
// 1. it's top will stick to the screen top,
// 2. it's bottom will stick to the screen bottom,
// 3. if not the cases above, then it's height gets changed
if (delta > 0 && pos === this.state.top) {
// case 1, and scrolling down
this.stickyTop = top - delta;
this.stickyBottom = this.stickyTop + height;
} else if (delta < 0 && pos === this.state.bottom - height) {
// case 2, and scrolling up
this.stickyBottom = bottom - delta;
this.stickyTop = this.stickyBottom - height;
} else if (pos !== this.state.bottom - height && pos !== this.state.top) {
// case 3
// This case only happens when Sticky's bottom sticks to the screen bottom and
// its height gets changed. Sticky should be in RELEASE status and update its
// sticky bottom by calculating how much height it changed.
const deltaHeight = pos + height - this.state.bottom;
this.stickyBottom = bottom - delta + deltaHeight;
this.stickyTop = this.stickyBottom - height;
} else {
toRelease = false;
}
if (toRelease) {
this.release(this.stickyTop);
}
break;
}
} else {
// In this case, Sticky is shorter then viewport minus top offset
// and will always fix to the top offset of viewport
this.fix(this.state.top);
}
}
this.delta = delta;
}
componentDidUpdate(prevProps, prevState) {
if (prevState.status !== this.state.status && this.props.onStateChange) {
this.props.onStateChange({
status: this.state.status
});
}
// check if we are up-to-date, is triggered in case of scroll restoration
if (this.state.top !== prevState.top) {
this.updateInitialDimension();
this.update();
}
const arePropsChanged = !shallowEqual(this.props, prevProps);
if (arePropsChanged) {
// if the props for enabling are toggled, then trigger the update or reset depending on the current props
if (prevProps.enabled !== this.props.enabled) {
if (this.props.enabled) {
this.setState({
activated: true
}, () => {
this.updateInitialDimension();
this.update();
});
} else {
this.setState({
activated: false
}, () => {
this.reset();
});
}
}
// if the top or bottomBoundary props were changed, then trigger the update
else if (prevProps.top !== this.props.top || prevProps.bottomBoundary !== this.props.bottomBoundary) {
this.updateInitialDimension();
this.update();
}
}
}
componentWillUnmount() {
const subscribers = this.subscribers || [];
for (var i = subscribers.length - 1; i >= 0; i--) {
this.subscribers[i].unsubscribe();
}
}
componentDidMount() {
// Only initialize the globals if this is the first
// time this component type has been mounted
if (!win) {
win = window;
doc = document;
docEl = doc.documentElement;
docBody = doc.body;
winHeight = win.innerHeight || docEl.clientHeight;
M = window.Modernizr;
// No Sticky on lower-end browser when no Modernizr
if (M && M.prefixed) {
canEnableTransforms = M.csstransforms3d;
TRANSFORM_PROP = M.prefixed('transform');
}
}
// when mount, the scrollTop is not necessary on the top
this.scrollTop = docBody.scrollTop + docEl.scrollTop;
if (this.props.enabled) {
this.setState({
activated: true
});
this.updateInitialDimension();
this.update();
}
// bind the listeners regardless if initially enabled - allows the component to toggle sticky functionality
this.subscribers = [subscribe('scrollStart', this.handleScrollStart.bind(this), {
useRAF: true
}), subscribe('scroll', this.handleScroll.bind(this), {
useRAF: true,
enableScrollInfo: true
}), subscribe('resize', this.handleResize.bind(this), {
enableResizeInfo: true
})];
}
translate(style, pos) {
const enableTransforms = canEnableTransforms && this.props.enableTransforms;
if (enableTransforms && this.state.activated) {
style[TRANSFORM_PROP] = 'translate3d(0,' + Math.round(pos) + 'px,0)';
} else {
style.top = pos + 'px';
}
}
shouldComponentUpdate(nextProps, nextState) {
return !this.props.shouldFreeze() && !(shallowEqual(this.props, nextProps) && shallowEqual(this.state, nextState));
}
render() {
// TODO, "overflow: auto" prevents collapse, need a good way to get children height
const innerStyle = {
position: this.state.status === STATUS_FIXED ? 'fixed' : 'relative',
top: this.state.status === STATUS_FIXED ? '0px' : '',
zIndex: this.props.innerZ
};
const outerStyle = {};
// always use translate3d to enhance the performance
this.translate(innerStyle, this.state.pos);
if (this.state.status !== STATUS_ORIGINAL) {
innerStyle.width = this.state.width + 'px';
outerStyle.height = this.state.height + 'px';
}
const outerClasses = classNames('sticky-outer-wrapper', this.props.className, {
[this.props.activeClass]: this.state.status === STATUS_FIXED,
[this.props.releasedClass]: this.state.status === STATUS_RELEASED
});
const innerClasses = classNames('sticky-inner-wrapper', this.props.innerClass, {
[this.props.innerActiveClass]: this.state.status === STATUS_FIXED
});
const children = this.props.children;
return /*#__PURE__*/React.createElement("div", {
ref: outer => {
this.outerElement = outer;
},
className: outerClasses,
style: outerStyle
}, /*#__PURE__*/React.createElement("div", {
ref: inner => {
this.innerElement = inner;
},
className: innerClasses,
style: innerStyle
}, typeof children === 'function' ? children({
status: this.state.status
}) : children));
}
}
Sticky.displayName = 'Sticky';
Sticky.defaultProps = {
shouldFreeze: function () {
return false;
},
enabled: true,
top: 0,
bottomBoundary: 0,
enableTransforms: true,
activeClass: 'active',
releasedClass: 'released',
onStateChange: null,
innerClass: '',
innerActiveClass: ''
};
/**
* @param {Bool} enabled A switch to enable or disable Sticky.
* @param {String/Number} top A top offset px for Sticky. Could be a selector representing a node
* whose height should serve as the top offset.
* @param {String/Number} bottomBoundary A bottom boundary px on document where Sticky will stop.
* Could be a selector representing a node whose bottom should serve as the bottom boudary.
*/
Sticky.propTypes = {
children: PropTypes.element,
enabled: PropTypes.bool,
top: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
bottomBoundary: PropTypes.oneOfType([PropTypes.object,
// TODO, may remove
PropTypes.string, PropTypes.number]),
enableTransforms: PropTypes.bool,
activeClass: PropTypes.string,
releasedClass: PropTypes.string,
innerClass: PropTypes.string,
innerActiveClass: PropTypes.string,
className: PropTypes.string,
onStateChange: PropTypes.func,
shouldFreeze: PropTypes.func,
innerZ: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
};
Sticky.STATUS_ORIGINAL = STATUS_ORIGINAL;
Sticky.STATUS_RELEASED = STATUS_RELEASED;
Sticky.STATUS_FIXED = STATUS_FIXED;
export default Sticky;