UNPKG

react-sticky-state

Version:

React StickyState Component makes native position:sticky statefull and polyfills the missing sticky browser feature

649 lines (535 loc) 18.5 kB
import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import ScrollFeatures from 'scrollfeatures'; import assign from 'object-assign'; import Can from './featureDetect'; var log = function() {}; const initialState = { initialized: false, sticky: false, absolute: false, fixedOffset: '', offsetHeight: 0, bounds: { top: null, left: null, right: null, bottom: null, height: null, width: null }, restrict: { top: null, left: null, right: null, bottom: null, height: null, width: null }, wrapperStyle: null, elementStyle: null, initialStyle: null, style: { top: null, bottom: null, left: null, right: null, 'margin-top': 0, 'margin-bottom': 0, 'margin-left': 0, 'margin-right': 0 }, disabled: false }; const getAbsolutBoundingRect = (el, fixedHeight) => { var rect = el.getBoundingClientRect(); var top = rect.top + ScrollFeatures.windowY; var height = fixedHeight || rect.height; return { top: top, bottom: top + height, height: height, width: rect.width, left: rect.left, right: rect.right }; }; const addBounds = (rect1, rect2) => { var rect = assign({}, rect1); rect.top -= rect2.top; rect.left -= rect2.left; rect.right = rect.left + rect1.width; rect.bottom = rect.top + rect1.height; return rect; }; const getPositionStyle = el => { var result = {}; var style = window.getComputedStyle(el, null); for (var key in initialState.style) { var value = parseInt(style.getPropertyValue(key)); value = isNaN(value) ? null : value; result[key] = value; } return result; }; const getPreviousElementSibling = el => { var prev = el.previousElementSibling; if (prev && prev.tagName.toLocaleLowerCase() === 'script') { prev = getPreviousElementSibling(prev); } return prev; }; class ReactStickyState extends Component { static propTypes = { initialize: PropTypes.bool, wrapperClass: PropTypes.string, stickyClass: PropTypes.string, fixedClass: PropTypes.string, stateClass: PropTypes.string, disabledClass: PropTypes.string, absoluteClass: PropTypes.string, disabled: PropTypes.bool, debug: PropTypes.bool, wrapFixedSticky: PropTypes.bool, tagName: PropTypes.string, scrollClass: PropTypes.shape({ down : PropTypes.string, up : PropTypes.string, none : PropTypes.string, persist : PropTypes.bool, active : PropTypes.bool }) }; static defaultProps = { initialize: true, wrapperClass: 'sticky-wrap', stickyClass: 'sticky', fixedClass: 'sticky-fixed', stateClass: 'is-sticky', disabledClass: 'sticky-disabled', absoluteClass: 'is-absolute', wrapFixedSticky: true, debug: false, disabled: false, tagName: 'div', scrollClass: { down: null, up: null, none: null, persist: false, active: false } }; constructor(props, context) { super(props, context); this._updatingBounds = false; this._shouldComponentUpdate = true; this._updatingState = false; this.state = assign({}, initialState, {disabled:props.disabled}); if (props.debug === true) { log = console.log.bind(console); } } getBoundingClientRect() { return this.refs.el.getBoundingClientRect(); } getBounds(noCache) { var clientRect = this.getBoundingClientRect(); var offsetHeight = ScrollFeatures.documentHeight; noCache = noCache === true; if (noCache !== true && this.state.bounds.height !== null) { if (this.state.offsetHeight === offsetHeight && clientRect.height === this.state.bounds.height) { return { offsetHeight: offsetHeight, style: this.state.style, bounds: this.state.bounds, restrict: this.state.restrict }; } } // var style = noCache ? this.state.style : getPositionStyle(this.el); var initialStyle = this.state.initialStyle; if (!initialStyle) { initialStyle = getPositionStyle(this.refs.el); } var style = initialStyle; var child = this.refs.wrapper || this.refs.el; var rect; var restrict; var offsetY = 0; // var offsetX = 0; if (!Can.sticky) { rect = getAbsolutBoundingRect(child, clientRect.height); if (this.hasOwnScrollTarget) { var parentRect = getAbsolutBoundingRect(this.scrollTarget); offsetY = this.scroll.y; rect = addBounds(rect, parentRect); restrict = parentRect; restrict.top = 0; restrict.height = this.scroll.scrollHeight || restrict.height; restrict.bottom = restrict.height; } } else { var elem = getPreviousElementSibling(child); offsetY = 0; if (elem) { offsetY = parseInt(window.getComputedStyle(elem)['margin-bottom']); offsetY = offsetY || 0; rect = getAbsolutBoundingRect(elem); if (this.hasOwnScrollTarget) { rect = addBounds(rect, getAbsolutBoundingRect(this.scrollTarget)); offsetY += this.scroll.y; } rect.top = rect.bottom + offsetY; } else { elem = child.parentNode; offsetY = parseInt(window.getComputedStyle(elem)['padding-top']); offsetY = offsetY || 0; rect = getAbsolutBoundingRect(elem); if (this.hasOwnScrollTarget) { rect = addBounds(rect, getAbsolutBoundingRect(this.scrollTarget)); offsetY += this.scroll.y; } rect.top = rect.top + offsetY; } if (this.hasOwnScrollTarget) { restrict = getAbsolutBoundingRect(this.scrollTarget); restrict.top = 0; restrict.height = this.scroll.scrollHeight || restrict.height; restrict.bottom = restrict.height; } rect.height = child.clientHeight; rect.width = child.clientWidth; rect.bottom = rect.top + rect.height; } restrict = restrict || getAbsolutBoundingRect(child.parentNode); return { offsetHeight: offsetHeight, style: style, bounds: rect, initialStyle: initialStyle, restrict: restrict }; } updateBounds(silent, noCache , cb) { noCache = noCache === true; this._shouldComponentUpdate = silent !== true; this.setState(this.getBounds(noCache), () => { this._shouldComponentUpdate = true; if (cb) { cb(); } }); } // updateFixedOffset() { // if (this.hasOwnScrollTarget && !Can.sticky) { // if (this.state.sticky) { // this.setState({ fixedOffset: this.scrollTarget.getBoundingClientRect().top + 'px' }); // if (!this.hasWindowScrollListener) { // this.hasWindowScrollListener = true; // ScrollFeatures.getInstance(window).on('scroll:progress', this.updateFixedOffset); // } // } else { // this.setState({ fixedOffset: '' }); // if (this.hasWindowScrollListener) { // this.hasWindowScrollListener = false; // ScrollFeatures.getInstance(window).off('scroll:progress', this.updateFixedOffset); // } // } // } // } updateFixedOffset() { const fixedOffset = this.state.fixedOffset; if (this.state.sticky) { this.setState({ fixedOffset: (this.scrollTarget.getBoundingClientRect().top) + 'px;' }); } else { this.setState({ fixedOffset: '' }); } // if (fixedOffset !== this.state.fixedOffset) { // this.render(); // } } addSrollHandler() { if (!this.scroll) { var hasScrollTarget = ScrollFeatures.hasInstance(this.scrollTarget); this.scroll = ScrollFeatures.getInstance(this.scrollTarget); this.onScroll = this.onScroll.bind(this); this.scroll.on('scroll:start', this.onScroll); this.scroll.on('scroll:progress', this.onScroll); this.scroll.on('scroll:stop', this.onScroll); if (this.props.scrollClass.active) { this.onScrollDirection = this.onScrollDirection.bind(this); this.scroll.on('scroll:up', this.onScrollDirection); this.scroll.on('scroll:down', this.onScrollDirection); if (!this.props.scrollClass.persist) { this.scroll.on('scroll:stop', this.onScrollDirection); } } if (hasScrollTarget && this.scroll.scrollY > 0) { this.scroll.trigger('scroll:progress'); } } } removeSrollHandler() { if (this.scroll) { this.scroll.off('scroll:start', this.onScroll); this.scroll.off('scroll:progress', this.onScroll); this.scroll.off('scroll:stop', this.onScroll); if (this.props.scrollClass.active) { this.scroll.off('scroll:up', this.onScrollDirection); this.scroll.off('scroll:down', this.onScrollDirection); this.scroll.off('scroll:stop', this.onScrollDirection); } if(!this.scroll.hasListeners()){ this.scroll.destroy(); } this.onScroll = null; this.onScrollDirection = null; this.scroll = null; } } addResizeHandler() { if (!this.onResize) { this.onResize = this.update.bind(this); window.addEventListener('sticky:update', this.onResize, false); window.addEventListener('resize', this.onResize, false); window.addEventListener('orientationchange', this.onResize, false); } } removeResizeHandler() { if (this.onResize) { window.removeEventListener('sticky:update', this.onResize); window.removeEventListener('resize', this.onResize); window.removeEventListener('orientationchange', this.onResize); this.onResize = null; } } destroy() { this._updatingBounds = false; this._shouldComponentUpdate = false; this._updatingState = false; this.removeSrollHandler(); this.removeResizeHandler(); this.scrollTarget = null; } getScrollClasses(obj) { if (this.options.scrollClass.active) { obj = obj || {}; var direction = (this.scroll.y <= 0 || this.scroll.y + this.scroll.clientHeight >= this.scroll.scrollHeight) ? 0 : this.scroll.directionY; obj[this.options.scrollClass.up] = direction < 0; obj[this.options.scrollClass.down] = direction > 0; } return obj; } getScrollClass() { if (this.props.scrollClass.up || this.props.scrollClass.down) { var direction = (this.scroll.y <= 0 || this.scroll.y + this.scroll.clientHeight >= this.scroll.scrollHeight) ? 0 : this.scroll.directionY; var scrollClass = direction < 0 ? this.props.scrollClass.up : this.props.scrollClass.down; scrollClass = direction === 0 ? null : scrollClass; return scrollClass; } return null; } onScrollDirection(e) { if (this.state.sticky ||( e && e.type === ScrollFeatures.events.SCROLL_STOP)) { this.setState({ scrollClass: this.getScrollClass() }) } } onScroll(e) { this.updateStickyState(false); if (this.hasOwnScrollTarget && !Can.sticky) { this.updateFixedOffset(); if (this.state.sticky && !this.hasWindowScrollListener) { this.hasWindowScrollListener = true; ScrollFeatures.getInstance(window).on('scroll:progress', this.updateFixedOffset); } else if (!this.state.sticky && this.hasWindowScrollListener) { this.hasWindowScrollListener = false; ScrollFeatures.getInstance(window).off('scroll:progress', this.updateFixedOffset); } } } update() { // this.scroll.updateScrollPosition(); this.updateBounds(true, true, ()=>{ this.updateStickyState(false); }); } // update(force = false) { // if (!this._updatingBounds) { // this._updatingBounds = true; // this.scroll.updateScrollPosition(); // this.updateBounds(true, true, () => { // this.updateBounds(force, true, () => { // this.scroll.updateScrollPosition(); // var updateSticky = this.updateStickyState(false, () => { // if (force && !updateSticky) { // this.forceUpdate(); // } // }); // this._updatingBounds = false; // }); // }); // } // } getStickyState() { if (this.state.disabled) { return { sticky: false, absolute: false }; } var scrollY = this.scroll.y; // var scrollX = this.scroll.x; var top = this.state.style.top; var bottom = this.state.style.bottom; // var left = this.state.style.left; // var right = this.state.style.right; var sticky = this.state.sticky; var absolute = this.state.absolute; if (top !== null) { var offsetBottom = this.state.restrict.bottom - this.state.bounds.height - top; top = this.state.bounds.top - top; if (this.state.sticky === false && ((scrollY >= top && scrollY <= offsetBottom) || (top <= 0 && scrollY < top))) { sticky = true; absolute = false; } else if (this.state.sticky && (top > 0 && scrollY < top || scrollY > offsetBottom)) { sticky = false; absolute = scrollY > offsetBottom; } } else if (bottom !== null) { scrollY += window.innerHeight; var offsetTop = this.state.restrict.top + this.state.bounds.height - bottom; bottom = this.state.bounds.bottom + bottom; if (this.state.sticky === false && scrollY <= bottom && scrollY >= offsetTop) { sticky = true; absolute = false; } else if (this.state.sticky && (scrollY > bottom || scrollY < offsetTop)) { sticky = false; absolute = scrollY <= offsetTop; } } return { sticky, absolute }; } updateStickyState(silent) { var values = this.getStickyState(); if (values.sticky !== this.state.sticky || values.absolute !== this.state.absolute) { this._shouldComponentUpdate = silent !== true; values = assign(values, this.getBounds(false)); this._updatingState = true; this.setState(values, ()=>{ this._shouldComponentUpdate = true; this._updatingState = false; }); } } // updateStickyState(bounds = true, cb) { // if (this._updatingState) { // return; // } // var values = this.getStickyState(); // if (values.sticky !== this.state.sticky || values.absolute !== this.state.absolute) { // this._updatingState = true; // if (bounds) { // values = assign(values, this.getBounds(), { scrollClass: this.getScrollClass() }); // } // this.setState(values, () => { // this._updatingState = false; // if (typeof cb === 'function') { // cb(); // } // }); // return true; // } else if (typeof cb === 'function') { // cb(); // } // return false; // } initialize() { if(!this.state.initialized && !this.state.disabled){ this.setState({ initialized:true },()=>{ var child = this.refs.wrapper || this.refs.el; this.scrollTarget = ScrollFeatures.getScrollParent(child); this.hasOwnScrollTarget = this.scrollTarget !== window; if (this.hasOwnScrollTarget) { this.updateFixedOffset = this.updateFixedOffset.bind(this); } this.addSrollHandler(); this.addResizeHandler(); this.update(); }) } } shouldComponentUpdate(newProps, newState) { return this._shouldComponentUpdate; } componentWillReceiveProps(nextProps) { const intialize = !this.state.initialized && nextProps.initialize; if (nextProps.disabled !== this.state.disabled) { this.setState({ disabled: nextProps.disabled }, ()=>{ if(intialize){ this.initialize(); } }); } } componentDidMount() { if(!this.state.initialized && this.props.initialize){ this.initialize(); } } componentWillUnmount() { this.destroy(); } render() { if(!this.state.initialized){ return this.props.children; } let element = React.Children.only(this.props.children); const { wrapperClass, stickyClass, fixedClass, stateClass, disabledClass, absoluteClass, disabled, debug, tagName, ...props } = this.props; var style; const refName = 'el'; const className = classNames({ [stickyClass]: !this.state.disabled, [disabledClass]: this.state.disabled }, { [fixedClass]: !Can.sticky }, { [stateClass]: this.state.sticky && !this.state.disabled }, { [absoluteClass]: this.state.absolute }, this.state.scrollClass); if (!Can.sticky) { if (this.state.absolute) { style = { marginTop: this.state.style.top !== null ? (this.state.restrict.height - (this.state.bounds.height + this.state.style.top) + (this.state.restrict.top - this.state.bounds.top)) + 'px' : '', marginBottom: this.state.style.bottom !== null ? (this.state.restrict.height - (this.state.bounds.height + this.state.style.bottom) + (this.state.restrict.bottom - this.state.bounds.bottom)) + 'px' : '' }; } else if (this.hasOwnScrollTarget && this.state.fixedOffset !== '') { style = { marginTop: this.state.fixedOffset }; } } if (element) { element = React.cloneElement(element, { ref: refName, style: style, className: classNames(element.props.className, className) }); } else { const Comp = this.props.tagName; element = <Comp ref={ refName } style={ style } className={ className } {...props }>{this.props.children}</Comp>; } if (Can.sticky) { return element; } const height = (this.state.disabled || this.state.bounds.height === null /*|| (!this.state.sticky && !this.state.absolute)*/) ? 'auto' : this.state.bounds.height + 'px'; const marginTop = height === 'auto' ? '' : (this.state.style['margin-top'] ? this.state.style['margin-top'] + 'px' : ''); const marginBottom = height === 'auto' ? '' : (this.state.style['margin-bottom'] ? this.state.style['margin-bottom'] + 'px' : ''); style = { height: height, marginTop: marginTop, marginBottom: marginBottom }; if (this.state.absolute) { style.position = 'relative'; } return (<div ref='wrapper' className={wrapperClass} style={style}>{element}</div>); } } export default ReactStickyState;