UNPKG

react-scroll-component

Version:
547 lines (473 loc) 16.6 kB
import React, { PureComponent } from 'react'; import DIRECTION_CONFIG from './config'; export class Scroll extends PureComponent { static resizeDebounce = 400; static initTimeout = 200; static debounce = (fn, time = 0) => { let timeout; return (...args) => { const functionCall = () => fn.apply(this, args); clearTimeout(timeout); timeout = setTimeout(functionCall, time); }; }; static setStyleTag() { const [head] = document.getElementsByTagName('head'); const hasStyleTag = !!document.getElementById('react-scroll-component'); if (!hasStyleTag) { const styleTag = document.createElement('style'); styleTag.id = 'react-scroll-component'; styleTag.innerHTML = ` .react-scroll-component { -ms-overflow-style: none; overflow: -moz-scrollbars-none; scrollbar-width: none; } .react-scroll-component::-webkit-scrollbar { display: none; width: 0 !important; } :-moz-any(.react-scroll-component[data-direction="vertical"]) { margin-right: -17px !important; overflow-x:hidden; } :-moz-any(.react-scroll-component[data-direction="horizontal"]) { margin-bottom: -17px !important; overflow-y:hidden; }`; head.appendChild(styleTag); } } _isMounted = false; dimensionChangeTimeoutIndex = null; observeTimeoutIndex = null; // eslint-disable-next-line react/destructuring-assignment config = DIRECTION_CONFIG[this.props.direction]; state = { compensation: 0, containerStyles: { // eslint-disable-next-line react/destructuring-assignment [this.config.scrollDimension]: this.props[this.config.scrollDimension], [`max${this.config.scrollDimension[0].toUpperCase()}${this.config.scrollDimension.slice( 1 )}`]: // eslint-disable-next-line react/destructuring-assignment this.props[ `max${this.config.scrollDimension[0].toUpperCase()}${this.config.scrollDimension.slice( 1 )}` ] || 'none', overflowX: this.config.overflow.x, overflowY: this.config.overflow.y, willChange: 'scroll-position' }, moving: false, // eslint-disable-next-line react/destructuring-assignment observe: this.props.observe !== undefined ? this.props.observe : true, scrollerStyles: { cursor: 'pointer', // eslint-disable-next-line react/destructuring-assignment ...this.props.scroller, position: 'absolute', // See https://www.chromestatus.com/features/5093566007214080 // and https://developers.google.com/web/updates/2017/01/scrolling-intervention touchAction: 'none', transition: `${this.config.scrollDimension} 300ms ease-in-out`, willChange: 'transform' }, scrollerTranslate: 0, wrapperStyles: { position: 'relative', // eslint-disable-next-line react/destructuring-assignment display: this.props.display || 'inline-block', [this.config.wrapper.overflowDimension]: 'auto', // eslint-disable-next-line react/destructuring-assignment width: this.props.direction === 'horizontal' ? this.props.width : 'auto', overflow: 'hidden' } }; observer = null; container = React.createRef(); scroller = React.createRef(); track = React.createRef(); wrapper = React.createRef(); getPagePosition(event, touchEvent, mouseEvent) { let position = 0; if (event.type === touchEvent) { position = event.changedTouches[0][this.config.pageCoordinate]; } else if (event.type === mouseEvent) { position = event[this.config.pageCoordinate]; } return position; } scrollerResizeAndTranslate = () => { this.setScrollerSize(this.setScrollerTranslate); }; init() { document.addEventListener('mousemove', this.moveScroller); document.addEventListener('mouseup', this.stopMovingScroller); window.addEventListener('resize', this.resizeHandler); const { observe } = this.state; const { initTimeout, noInitTimeout, observerTimeout } = this.props; if (observe) { const observerConfig = { characterData: true, childList: true, subtree: true }; this.observer = new MutationObserver(() => { if (typeof observerTimeout === 'number') { clearTimeout(this.observeTimeoutIndex); this.observeTimeoutIndex = setTimeout( this.scrollerResizeAndTranslate, observerTimeout ); } else { this.scrollerResizeAndTranslate(); } }); if (this.container.current) { this.observer.observe(this.container.current, observerConfig); } } if (noInitTimeout) { this.setScrollerSize(this.setScrollerTranslate); } else { setTimeout( () => { this.setScrollerSize(this.setScrollerTranslate); }, typeof initTimeout === 'number' ? initTimeout : Scroll.initTimeout ); } } scrollerSizeSetter = (cb) => { if (!this.container.current) { return; } const { minScrollerSize } = this.props; const o = this.container.current[this.config.container.offsetDimension]; const s = this.container.current[this.config.container.scrollDimension]; const relativeSize = Math.floor(o ** 2 / s); let size = Math.max(relativeSize, minScrollerSize || 0); size = Math.min(size, o); if (this._isMounted) { this.setState( (prevState) => ({ scrollerStyles: { ...prevState.scrollerStyles, display: s <= o ? 'none' : 'block', [this.config.scrollDimension]: `${size}px` } }), cb ); } }; debouncedScrollerSizeSetter = Scroll.debounce( this.scrollerSizeSetter, // eslint-disable-next-line react/destructuring-assignment this.props.scrollSizeDebounce ); setScrollerSize(cb) { const { scrollSizeDebounce } = this.props; if (typeof scrollSizeDebounce === 'number') { this.debouncedScrollerSizeSetter(cb); } else { this.scrollerSizeSetter(cb); } } startMovingScroller = (event) => { if (!this.wrapper.current) { return; } event.preventDefault(); event.stopPropagation(); const { scrollerTranslate } = this.state; const pagePosition = this.getPagePosition(event, 'touchstart', 'mousedown'); if (this._isMounted) { this.setState({ compensation: pagePosition - scrollerTranslate - this.wrapper.current[this.config.wrapper.offsetSide], moving: true }); } }; moveScroller = (event) => { const { compensation, moving } = this.state; const { direction } = this.props; if (moving && this.scroller.current && this.wrapper.current) { const pagePosition = this.getPagePosition( event, 'touchmove', 'mousemove' ); const max = this.wrapper.current[this.config.wrapper.clientDimension] - this.scroller.current[this.config.scroller.clientDimension]; let translate = pagePosition - this.wrapper.current[this.config.wrapper.offsetSide] - compensation; translate = Math.max(0, translate); translate = Math.min(max, translate); if (this._isMounted) { this.setState((prevState) => { let transform = ''; if (direction === 'horizontal') { transform = `translate3d(${translate}px,0px,0px)`; } else if (direction === 'vertical') { transform = `translate3d(0px,${translate}px,0px)`; } return { scrollerStyles: { ...prevState.scrollerStyles, transform, WebkitTransform: transform, MsTransform: transform }, scrollerTranslate: translate }; }); } this.setContainerScroll(translate); } }; setScrollerTranslate = () => { const { moving, scrollerStyles } = this.state; const { direction } = this.props; if (!moving && this.container.current) { const scr = this.container.current[this.config.container.scrollSide]; const o = this.container.current[this.config.container.offsetDimension]; const s = this.container.current[this.config.container.scrollDimension]; const scrollerSize = parseInt( scrollerStyles[this.config.scrollDimension], 10 ); const translate = ((o - scrollerSize) / (s - o)) * scr || 0; if (this._isMounted) { this.setState((prevState) => { let transform = ''; if (direction === 'horizontal') { transform = `translate3d(${translate}px,0px,0px)`; } else if (direction === 'vertical') { transform = `translate3d(0px,${translate}px,0px)`; } return { scrollerStyles: { ...prevState.scrollerStyles, transform, WebkitTransform: transform, MsTransform: transform }, scrollerTranslate: translate }; }); } } }; stopMovingScroller = (event) => { event.preventDefault(); event.stopPropagation(); if (this._isMounted) { this.setState({ moving: false }); } }; setContainerScroll(val) { if (!this.container.current || !this.scroller.current) { return; } const o = this.container.current[this.config.container.offsetDimension]; const s = this.container.current[this.config.container.scrollDimension]; const scrollerSize = this.scroller.current[this.config.scroller.offsetDimension]; if (requestAnimationFrame) { requestAnimationFrame(() => { if (this.container.current) { this.container.current[this.config.container.scrollSide] = ((s - o) / (o - scrollerSize)) * val; } }); } else { this.container.current[this.config.container.scrollSide] = ((s - o) / (o - scrollerSize)) * val; } } resizeHandler = Scroll.debounce( this.scrollerResizeAndTranslate, // eslint-disable-next-line react/destructuring-assignment this.props.resizeDebounce || Scroll.resizeDebounce ); clean() { document.removeEventListener('mousemove', this.moveScroller); document.removeEventListener('mouseup', this.stopMovingScroller); window.removeEventListener('resize', this.resizeHandler); if (this.observer) { this.observer.disconnect(); } clearTimeout(this.observeTimeoutIndex); clearTimeout(this.dimensionChangeTimeoutIndex); this.observer = null; } propagateWheelAsScrollOnContainer = (event) => { if (this.container && this.container.current) { // TODO: fix for horizontal too this.container.current.scrollTop += event.deltaY; } }; trackClickHandler = (event) => { event.stopPropagation(); if ( !this.container || !this.container.current || !this.scroller || !this.scroller.current || !this.track || !this.track.current || !this.wrapper || !this.wrapper.current ) { return; } const { onTrackClick, trackShift } = this.props; let direction = null; const { top: scrollerTop, bottom: scrollerBottom } = this.scroller.current.getBoundingClientRect(); const clickIsAboveScroller = event.clientY < scrollerTop; const clickIsBelowScroller = event.clientY > scrollerBottom; if (clickIsAboveScroller) { direction = -1; } else if (clickIsBelowScroller) { direction = 1; } if (typeof onTrackClick === 'function') { onTrackClick({ container: this.container.current, direction, event, track: this.track.current }); } else if (typeof trackShift === 'number') { this.container.current.scrollTop += direction * trackShift; } }; dimensionChangeHandler = () => { const { scrollDimension } = this.config; const maxScrollDimension = `max${scrollDimension[0].toUpperCase()}${scrollDimension.slice( 1 )}`; if (this._isMounted) { this.setState( (prevState) => ({ containerStyles: { ...prevState.containerStyles, // eslint-disable-next-line react/destructuring-assignment [scrollDimension]: this.props[scrollDimension], // eslint-disable-next-line react/destructuring-assignment [maxScrollDimension]: this.props[maxScrollDimension] || 'none' } }), this.scrollerResizeAndTranslate ); } }; // ************************************************* // ************** Lifecycle Methods **************** // ************************************************* render() { const { containerStyles, scrollerStyles, wrapperStyles } = this.state; const { children, className, containerClass, direction, scrollerClass, track, trackClass } = this.props; return children ? ( <div className={className} ref={this.wrapper} style={wrapperStyles}> {track && scrollerStyles.display === 'block' ? ( <div ref={this.track} className={trackClass} style={{ position: 'absolute', height: '100%' }} onClick={this.trackClickHandler} onWheel={this.propagateWheelAsScrollOnContainer} /> ) : null} <div className={scrollerClass} onMouseDown={this.startMovingScroller} onTouchStart={this.startMovingScroller} onTouchMove={this.moveScroller} onTouchEnd={this.stopMovingScroller} onWheel={this.propagateWheelAsScrollOnContainer} ref={this.scroller} style={scrollerStyles} /> <div data-direction={direction} className={['react-scroll-component', containerClass] .filter(Boolean) .join(' ')} onScroll={this.setScrollerTranslate} ref={this.container} style={containerStyles} onTransitionEnd={this.scrollerResizeAndTranslate} > {children} </div> </div> ) : null; } componentDidMount() { const { containerRef } = this.props; this._isMounted = true; Scroll.setStyleTag(); this.init(); if (typeof containerRef === 'function') { containerRef(this.container); } } componentDidUpdate(prevProps, prevState) { const { scrollDimension } = this.config; const { scrollerStyles } = this.state; const { dimensionChangeTimeout, onScrollerToggle } = this.props; const maxScrollDimension = `max${scrollDimension[0].toUpperCase()}${scrollDimension.slice( 1 )}`; if ( // eslint-disable-next-line react/destructuring-assignment this.props[scrollDimension] !== prevProps[scrollDimension] || // eslint-disable-next-line react/destructuring-assignment this.props[maxScrollDimension] !== prevProps[maxScrollDimension] ) { if (typeof dimensionChangeTimeout === 'number') { clearTimeout(this.dimensionChangeTimeoutIndex); this.dimensionChangeTimeoutIndex = setTimeout(() => { this.dimensionChangeHandler(); }, dimensionChangeTimeout); } else { this.dimensionChangeHandler(); } } if (prevState.scrollerStyles.display !== scrollerStyles.display) { if (typeof onScrollerToggle === 'function') { onScrollerToggle({ isDisplayed: scrollerStyles.display === 'block' }); } } } componentWillUnmount() { this._isMounted = false; this.clean(); } } export default Scroll;