react-scroll-component
Version:
Custom react scroll component
547 lines (473 loc) • 16.6 kB
JSX
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;