UNPKG

@wix/design-system

Version:

@wix/design-system

440 lines 19.6 kB
import React, { Children, PureComponent } from 'react'; import PropTypes from 'prop-types'; import { st, classes, vars } from './CarouselWIP.st.css.js'; import { ChevronLeftSmall, ChevronRightSmall } from '@wix/wix-ui-icons-common'; import Loader from '../Loader'; import Control from './Control'; import Slide from './Slide'; import { CONTROLS_START_END, SLIDING_TYPE, ALIGNMENT, DATA_HOOKS, AUTOPLAY_SPEED, SWIPE_THRESHOLD, } from './constants'; import { isWhollyInView, animate, nop, normalizeIndex } from './utils'; /** The carousel component creates a slideshow for cycling through a series of content. */ class CarouselWIP extends PureComponent { constructor(props) { super(props); this.loadingImagesCount = 0; this.childCount = 0; this.autoplayTimer = -1; this.dragStartX = null; this.initialScrollLeft = 0; this.capturedPointerId = null; this.hasSetPointerCapture = false; // Need to wait for images to load so we know which images are visible // Adding onLoad and onError callbacks to all images under the component this._setImagesOnLoadHandlers = () => { Array.from(this.carousel.children).forEach(child => { const childImages = Array.from(child.getElementsByTagName('img')); childImages.forEach(img => { if (img.complete) { return; } this.setState({ isLoading: true }); this.loadingImagesCount++; img.onload = this._onImageLoad; img.onerror = this._onImageLoad; }); }); }; this._updateChildCount = () => { const { images } = this.props; this.childCount = this.carousel?.children?.length ?? images?.length ?? 0; }; this._onImageLoad = () => { const { initialSlideIndex } = this.props; this.loadingImagesCount--; if (!this.loadingImagesCount) { this.setState({ isLoading: false }); this._slideTo({ index: initialSlideIndex, immediate: true, }).catch(nop); } }; this._setAutoplayTimer = (active) => { clearInterval(this.autoplayTimer); if (active) { this.autoplayTimer = window.setInterval(this._next, AUTOPLAY_SPEED); } }; this._setVisibleSlides = () => { const { props, carousel, childCount } = this; const { infinite } = props; const firstVisibleChild = Math.max(Array.from(carousel.children).findIndex(child => isWhollyInView(carousel)(child)), 0); const lastVisibleChild = Math.max(Array.from(carousel.children).findIndex((child, i, children) => isWhollyInView(carousel)(child) && (i === children.length - 1 || !isWhollyInView(carousel)(children[i + 1]))), 0); this.setState({ visibleSlides: [firstVisibleChild, lastVisibleChild], isLeftArrowDisabled: !infinite && firstVisibleChild === 0, isRightArrowDisabled: !infinite && lastVisibleChild === childCount - 1, isShowStartGradient: firstVisibleChild > 0, isShowEndGradient: lastVisibleChild < childCount - 1, }); }; this._slideTo = ({ index = 0, alignTo = ALIGNMENT.LEFT, immediate } = { index: 0, alignTo: ALIGNMENT.LEFT, immediate: false, }) => { if (this.childCount === 0) { return Promise.reject('No children to slide to'); } if (!this.carousel) { return Promise.reject('The Carousel is not mounted'); } const { afterChange, beforeChange, easing, animationDuration: duration, infinite, startEndOffset = 0, } = this.props; const { children, scrollLeft, offsetWidth } = this.carousel; const slideIndex = normalizeIndex(index, this.childCount, infinite); const { visibleSlides } = this.state; const [firstVisibleSlide] = visibleSlides; let delta; const child = children[slideIndex]; if (alignTo === ALIGNMENT.RIGHT) { delta = child.offsetWidth - (offsetWidth - child.offsetLeft) - scrollLeft + startEndOffset; } else { delta = child.offsetLeft - scrollLeft - startEndOffset; } if (firstVisibleSlide !== slideIndex && beforeChange) { beforeChange(firstVisibleSlide, index); } this.setState({ isAnimating: true }); return new Promise((res, _) => { if (immediate) { this.carousel.scrollLeft = child.offsetLeft; return res(); } else { const prop = 'scrollLeft'; return res(animate(this.carousel, { prop, delta, easing, duration, })); } }) .then(() => { this.setState({ isAnimating: false }); this._setVisibleSlides(); if (firstVisibleSlide !== slideIndex && afterChange) { return afterChange(slideIndex); } }) .catch(_ => { this._setVisibleSlides(); this.setState({ isAnimating: false }); }); }; this._next = () => { const { slidingType, infinite } = this.props; const { visibleSlides } = this.state; const [firstVisibleSlide, lastVisibleSlide] = visibleSlides; let nextSlide, alignTo; if ([SLIDING_TYPE.REVEAL_CHUNK, SLIDING_TYPE.REVEAL_ONE].includes(slidingType)) { if (lastVisibleSlide === this.childCount - 1) { nextSlide = infinite ? 0 : lastVisibleSlide; } else { nextSlide = lastVisibleSlide + 1; } alignTo = slidingType === SLIDING_TYPE.REVEAL_CHUNK ? ALIGNMENT.LEFT : ALIGNMENT.RIGHT; } else { if (firstVisibleSlide === this.childCount - 1) { nextSlide = infinite ? 0 : firstVisibleSlide; } else { nextSlide = firstVisibleSlide + 1; } alignTo = ALIGNMENT.LEFT; } if (nextSlide === this.childCount - 1) { this.setState({ isRightArrowDisabled: true, isShowEndGradient: false }); } if (firstVisibleSlide === 0) { this.setState({ isLeftArrowDisabled: false, isShowStartGradient: true }); } return this._slideTo({ index: nextSlide, alignTo }); }; this._prev = () => { const { slidingType, infinite } = this.props; const { visibleSlides } = this.state; const [firstVisibleSlide, lastVisibleSlide] = visibleSlides; let prevSlide, alignTo; if ([SLIDING_TYPE.REVEAL_CHUNK, SLIDING_TYPE.REVEAL_ONE].includes(slidingType)) { if (firstVisibleSlide === 0) { prevSlide = infinite ? this.childCount - 1 : firstVisibleSlide; } else { prevSlide = firstVisibleSlide - 1; } alignTo = slidingType === SLIDING_TYPE.REVEAL_CHUNK ? ALIGNMENT.RIGHT : ALIGNMENT.LEFT; } else { if (firstVisibleSlide === 0) { prevSlide = infinite ? this.childCount - 1 : 0; } else { prevSlide = firstVisibleSlide - 1; } alignTo = ALIGNMENT.LEFT; } if (prevSlide === 0) { this.setState({ isLeftArrowDisabled: true, isShowStartGradient: false }); } if (lastVisibleSlide === this.childCount - 1) { this.setState({ isRightArrowDisabled: false, isShowEndGradient: true }); } return this._slideTo({ index: prevSlide, alignTo }); }; this._setRef = (r) => { this.carousel = r; }; this._renderLeftControl = () => { const { isLeftArrowDisabled } = this.state; const { controlsPosition, controlsStartEnd, controlsSize, controlsSkin } = this.props; return (controlsPosition !== 'none' && (!isLeftArrowDisabled || controlsStartEnd === CONTROLS_START_END.DISABLED) && (React.createElement(Control, { dataHook: DATA_HOOKS.prevButton, onClick: this._prev, icon: React.createElement(ChevronLeftSmall, null), size: controlsSize, skin: controlsSkin, disabled: isLeftArrowDisabled, className: st(classes.control, classes.prev) }))); }; this._renderRightControl = () => { const { isRightArrowDisabled } = this.state; const { controlsPosition, controlsStartEnd, controlsSize, controlsSkin } = this.props; return (controlsPosition !== 'none' && (!isRightArrowDisabled || controlsStartEnd === CONTROLS_START_END.DISABLED) && (React.createElement(Control, { dataHook: DATA_HOOKS.nextButton, onClick: this._next, icon: React.createElement(ChevronRightSmall, null), size: controlsSize, skin: controlsSkin, disabled: isRightArrowDisabled, className: st(classes.control, classes.next) }))); }; this._handlePointerDown = (e) => { if (!e.isPrimary) return; e.preventDefault(); this.dragStartX = e.clientX; this.initialScrollLeft = this.carousel.scrollLeft; this.capturedPointerId = e.pointerId; this.setState({ isDragging: true, }); }; this.requestAnimationFrameId = null; this._handlePointerMove = (e) => { const { isDragging } = this.state; if (!isDragging || !this.dragStartX || e.pointerId !== this.capturedPointerId) { return; } e.preventDefault(); if (!this.hasSetPointerCapture) { e.currentTarget.setPointerCapture(e.pointerId); this.hasSetPointerCapture = true; } if (this.requestAnimationFrameId) { cancelAnimationFrame(this.requestAnimationFrameId); } const swipeDistance = e.clientX - this.dragStartX; this.requestAnimationFrameId = requestAnimationFrame(() => { this.carousel.scrollLeft = this.initialScrollLeft - swipeDistance; }); }; this._handlePointerUp = (e) => { const { isDragging } = this.state; if (!isDragging || !this.dragStartX || e.pointerId !== this.capturedPointerId) return; this._handleSwipeEnd(e); this._releasePointerCapture(e.pointerId); this._resetDragState(); }; this._handlePointerCancel = (e) => { if (e.pointerId !== this.capturedPointerId || !this.dragStartX) { return; } this._handleSwipeEnd(e); this._releasePointerCapture(e.pointerId); this._resetDragState(); }; this._handleSwipeEnd = (e) => { if (!this.dragStartX) return; const swipeDistance = e.clientX - this.dragStartX; if (Math.abs(swipeDistance) > SWIPE_THRESHOLD) { if (swipeDistance > 0) { // Swiped right - go to previous slide this._prev(); } else { // Swiped left - go to next slide this._next(); } } else { const { easing, animationDuration: duration } = this.props; // sliding back to the original position animate(this.carousel, { prop: 'scrollLeft', delta: swipeDistance, easing, duration, }); } }; this._releasePointerCapture = (capturedPointerId) => { if (this.carousel?.hasPointerCapture(capturedPointerId)) { this.carousel?.releasePointerCapture(capturedPointerId); } }; this._resetDragState = () => { this.dragStartX = null; this.initialScrollLeft = 0; this.capturedPointerId = null; this.hasSetPointerCapture = false; this.setState({ isDragging: false, }); }; this._renderSlides = () => { const { images = [], children, gutter, imagesPosition, imagesFit, } = this.props; const slide = ({ i, image, child, }) => (React.createElement(Slide, { dataHook: DATA_HOOKS.child, className: classes.slide, key: `slide-${i}`, width: "auto", gutter: i > 0 && gutter != null ? `${gutter}px` : undefined, image: image, children: child, imagePosition: imagesPosition, imageFit: imagesFit })); return (React.createElement("div", { className: st(classes.carousel, { dragging: this.state.isDragging }), role: "list", ref: this._setRef, "data-hook": DATA_HOOKS.carousel, onPointerDown: this._handlePointerDown, onPointerMove: this._handlePointerMove, onPointerUp: this._handlePointerUp, onPointerCancel: this._handlePointerCancel }, Children.count(children) ? Children.map(children, (child, i) => slide({ i, child })) : images.map((image, i) => slide({ i, image })))); }; this._renderLoader = () => (React.createElement("div", { className: classes.loader }, React.createElement(Loader, { dataHook: DATA_HOOKS.loader, size: "small" }))); this._renderDots = () => { const { children, images } = this.props; const { visibleSlides } = this.state; const [firstVisibleSlide, lastVisibleSlide] = visibleSlides; const slidesCount = Children.count(children) || images?.length || 0; return (React.createElement("div", { className: classes.dots }, Array(slidesCount) .fill(0) .map((_, index) => (React.createElement("div", { "data-hook": DATA_HOOKS.pageNavigation(index), key: index, className: st(classes.dot, { active: index >= firstVisibleSlide && index <= lastVisibleSlide, }), onClick: () => { if (index < firstVisibleSlide || index > lastVisibleSlide) this._slideTo({ index, alignTo: index > firstVisibleSlide ? ALIGNMENT.RIGHT : ALIGNMENT.LEFT, }); } }))))); }; this._renderStartGradient = () => React.createElement("div", { className: classes.start }); this._renderEndGradient = () => React.createElement("div", { className: classes.end }); this.loadingImagesCount = 0; this.state = { visibleSlides: [], isAnimating: false, isLoading: false, isLeftArrowDisabled: true, isRightArrowDisabled: true, isShowStartGradient: false, isShowEndGradient: false, isDragging: false, }; } componentDidMount() { const { initialSlideIndex = 0, autoplay } = this.props; this._updateChildCount(); this._setImagesOnLoadHandlers(); if (!this.loadingImagesCount) { this._slideTo({ index: initialSlideIndex, immediate: true }).catch(nop); this._setVisibleSlides(); } this._setAutoplayTimer(autoplay); } componentDidUpdate(prevProps) { const { autoplay } = this.props; if (prevProps.autoplay !== autoplay) this._setAutoplayTimer(autoplay); const lastCount = this.childCount; this._updateChildCount(); if (this.childCount && lastCount !== this.childCount) { this._setVisibleSlides(); } } render() { const { dataHook, className, controlsPosition, controlsSize, showControlsShadow, sidesGradientColor, hideDots, } = this.props; const { isShowStartGradient, isShowEndGradient, isLoading } = this.state; const showSidesGradients = !!sidesGradientColor; return isLoading ? (this._renderLoader()) : (React.createElement("div", { "data-hook": dataHook, className: st(classes.root, { controlsPosition, controlsSize, showControlsShadow, showSidesGradients, hideDots, }, className), style: { [vars.sidesGradientColor]: sidesGradientColor } }, React.createElement("div", { style: { position: 'relative' } }, showSidesGradients && isShowStartGradient && this._renderStartGradient(), this._renderLeftControl(), this._renderSlides(), this._renderRightControl(), showSidesGradients && isShowEndGradient && this._renderEndGradient()), !hideDots && this._renderDots())); } componentWillUnmount() { if (this.capturedPointerId) { this._releasePointerCapture(this.capturedPointerId); } this._setAutoplayTimer(false); } } CarouselWIP.displayName = 'CarouselWIP'; CarouselWIP.propTypes = { dataHook: PropTypes.any, className: PropTypes.any, children: PropTypes.any, images: PropTypes.any, controlsSkin: PropTypes.any, showControlsShadow: PropTypes.any, infinite: PropTypes.any, initialSlideIndex: PropTypes.any, afterChange: PropTypes.any, beforeChange: PropTypes.any, controlsPosition: PropTypes.any, controlsSize: PropTypes.any, controlsStartEnd: PropTypes.any, slidingType: PropTypes.any, startEndOffset: PropTypes.any, gutter: PropTypes.any, sidesGradientColor: PropTypes.any, imagesPosition: PropTypes.any, imagesFit: PropTypes.any, autoplay: PropTypes.any, hideDots: PropTypes.any, variableWidth: PropTypes.any, animationDuration: PropTypes.any, easing: PropTypes.any, }; CarouselWIP.defaultProps = { children: [], infinite: true, controlsSkin: 'standard', controlsStartEnd: 'disabled', showControlsShadow: false, images: [], initialSlideIndex: 0, controlsPosition: 'sides', controlsSize: 'medium', slidingType: 'align-to-start', startEndOffset: 0, gutter: undefined, hideDots: false, autoplay: false, }; export default CarouselWIP; //# sourceMappingURL=CarouselWIP.js.map