wix-style-react
Version:
532 lines (467 loc) • 15.2 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import { st, classes, vars } from './CarouselWIP.st.css';
import { ChevronLeftSmall, ChevronRightSmall } from '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,
} 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 React.PureComponent {
static displayName = 'CarouselWIP';
static propTypes = {
/** Applied as data-hook HTML attribute that can be used in the tests */
dataHook: PropTypes.string,
/** A css class to be applied to the component's root element */
className: PropTypes.string,
/** Any element to render inside */
children: PropTypes.node,
/** Array of objects where each contains the `src` of an image (in \<img src="your_src" /\>) */
images: PropTypes.array,
/** Sets the skin of the arrow buttons */
controlsSkin: PropTypes.oneOf(['standard', 'inverted', 'light']),
/** Show a shadow for the carousel controls */
showControlsShadow: PropTypes.bool,
/** Images loop endlessly */
infinite: PropTypes.bool,
/** An index of the slide to start on */
initialSlideIndex: PropTypes.number,
/** Index change callback. `index => ...` */
afterChange: PropTypes.func,
/** Index change callback. `(oldIndex, newIndex) => ...` */
beforeChange: PropTypes.func,
/** Sets the arrows position */
controlsPosition: PropTypes.oneOf(['sides', 'overlay', 'bottom', 'none']),
/** Sets the arrows position */
controlsSize: PropTypes.oneOf(['tiny', 'small', 'medium']),
/** Configure the start and end controls to be shown disabled or hidden. Relevant when infinite prop is set to false. */
controlsStartEnd: PropTypes.oneOf(['disabled', 'hidden']),
/** Sliding behaviour type for the carousel */
slidingType: PropTypes.oneOf([
'align-to-start',
'reveal-one',
'reveal-chunk',
]),
/** Number of pixels for showing "peeking" cards on the edges of the carousel */
startEndOffset: PropTypes.number,
/** Number of pixels dividing between slides */
gutter: PropTypes.number,
/** Color for the gradients on the sides of the carousel */
sidesGradientColor: PropTypes.string,
/** Sets the images position */
imagesPosition: PropTypes.string,
/** Sets the images fit */
imagesFit: PropTypes.oneOf([
'fill',
'contain',
'cover',
'none',
'scale-down',
]),
/** Auto-playing of images */
autoplay: PropTypes.bool,
/** Hide dots */
hideDots: PropTypes.bool,
// TODO: implement prop
/** 🚧 Variable width of children */
variableWidth: PropTypes.bool,
};
static defaultProps = {
children: [],
infinite: true,
controlsSkin: 'standard',
controlsStartEnd: 'disabled',
showControlsShadow: false,
images: [],
initialSlideIndex: 0,
controlsPosition: 'sides',
controlsSize: 'medium',
slidingType: 'align-to-start',
startEndOffset: 0,
gutter: 0,
hideDots: false,
autoplay: false,
};
constructor(props) {
super(props);
this.loadingImagesCount = 0;
this.state = {
visibleSlides: [],
isAnimating: false,
isLoading: false,
isLeftArrowDisabled: true,
isRightArrowDisabled: true,
isShowStartGradient: false,
isShowEndGradient: false,
};
}
componentDidMount() {
const { initialSlideIndex, 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();
}
}
// 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
_setImagesOnLoadHandlers = () => {
Array.from(this.carousel.children).forEach(child => {
const childImages = Array.from(child.getElementsByTagName('img'));
childImages.forEach(img => {
this.setState({ isLoading: true });
this.loadingImagesCount++;
img.onload = this._onImageLoad;
img.onerror = this._onImageLoad;
});
});
};
_updateChildCount = () => {
const { images } = this.props;
this.childCount = this.carousel?.children?.length ?? images?.length ?? 0;
};
_onImageLoad = () => {
const { initialSlideIndex } = this.props;
this.loadingImagesCount--;
if (!this.loadingImagesCount) {
this.setState({ isLoading: false });
this._slideTo({
index: initialSlideIndex,
immediate: true,
}).catch(nop);
}
};
_setAutoplayTimer = active => {
clearInterval(this.autoplayTimer);
if (active) this.autoplayTimer = setInterval(this._next, AUTOPLAY_SPEED);
};
_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,
});
};
_slideTo = (
{ index, alignTo, 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,
} = this.props;
const { children, scrollLeft, offsetWidth } = this.carousel;
const slideIndex = normalizeIndex(index, this.childCount, infinite);
const { visibleSlides } = this.state;
const [firstVisibleSlide, lastVisibleSlide] = visibleSlides;
let delta;
if (alignTo === ALIGNMENT.RIGHT) {
delta =
children[slideIndex].offsetWidth -
(offsetWidth - children[slideIndex].offsetLeft) -
scrollLeft +
startEndOffset;
} else {
delta = children[slideIndex].offsetLeft - scrollLeft - startEndOffset;
}
if (firstVisibleSlide !== slideIndex && beforeChange) {
beforeChange(firstVisibleSlide, index);
}
this.setState({ isAnimating: true });
return new Promise((res, _) => {
if (immediate) {
this.carousel.scrollLeft = children[slideIndex].offsetLeft;
return res();
} else {
const originalOverflowX = 'hidden';
const prop = 'scrollLeft';
return res(
animate(this.carousel, {
prop,
delta,
easing,
duration,
originalOverflowX,
}),
);
}
})
.then(() => {
this.setState({ isAnimating: false });
this._setVisibleSlides();
if (firstVisibleSlide !== slideIndex && afterChange) {
return afterChange(slideIndex);
}
})
.catch(_ => {
this._setVisibleSlides();
this.setState({ isAnimating: false });
});
};
_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 });
};
_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 });
};
_setRef = r => {
this.carousel = r;
};
_renderLeftControl = () => {
const { isLeftArrowDisabled } = this.state;
const { controlsPosition, controlsStartEnd, controlsSize, controlsSkin } =
this.props;
return (
controlsPosition !== 'none' &&
(!isLeftArrowDisabled ||
controlsStartEnd === CONTROLS_START_END.DISABLED) && (
<Control
dataHook={DATA_HOOKS.prevButton}
onClick={this._prev}
icon={<ChevronLeftSmall />}
size={controlsSize}
skin={controlsSkin}
disabled={isLeftArrowDisabled}
className={st(classes.control, classes.prev)}
/>
)
);
};
_renderRightControl = () => {
const { isRightArrowDisabled } = this.state;
const { controlsPosition, controlsStartEnd, controlsSize, controlsSkin } =
this.props;
return (
controlsPosition !== 'none' &&
(!isRightArrowDisabled ||
controlsStartEnd === CONTROLS_START_END.DISABLED) && (
<Control
dataHook={DATA_HOOKS.nextButton}
onClick={this._next}
icon={<ChevronRightSmall />}
size={controlsSize}
skin={controlsSkin}
disabled={isRightArrowDisabled}
className={st(classes.control, classes.next)}
/>
)
);
};
_renderSlides = () => {
const { images, children, gutter, imagesPosition, imagesFit } = this.props;
const slide = ({ i, image, child }) => (
<Slide
dataHook={DATA_HOOKS.child}
key={`slide-${i}`}
role="listitem"
width="auto"
gutter={i > 0 ? `${gutter}px` : ''}
image={image}
children={child}
imagePosition={imagesPosition}
imageFit={imagesFit}
/>
);
return (
<div className={classes.carousel} role="list" ref={this._setRef}>
{children.length
? React.Children.map(children, (child, i) => slide({ i, child }))
: images.map((image, i) => slide({ i, image }))}
</div>
);
};
_renderLoader = () => (
<div className={classes.loader}>
<Loader dataHook={DATA_HOOKS.loader} size="small" />
</div>
);
_renderDots = () => {
const { children, images } = this.props;
const { visibleSlides } = this.state;
const [firstVisibleSlide, lastVisibleSlide] = visibleSlides;
const slidesCount = children.length || images.length || 0;
return (
<div className={classes.dots}>
{Array(slidesCount)
.fill(0)
.map((_, index) => (
<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,
});
}}
/>
))}
</div>
);
};
_renderStartGradient = () => <div className={classes.start} />;
_renderEndGradient = () => <div className={classes.end} />;
render() {
const {
dataHook,
className,
controlsPosition,
controlsSize,
showControlsShadow,
sidesGradientColor,
hideDots,
} = this.props;
const { isShowStartGradient, isShowEndGradient, isLoading } = this.state;
const showSidesGradients = !!sidesGradientColor;
return isLoading ? (
this._renderLoader()
) : (
<div
data-hook={dataHook}
className={st(
classes.root,
{
controlsPosition,
controlsSize,
showControlsShadow,
showSidesGradients,
},
className,
)}
style={{ [vars.sidesGradientColor]: sidesGradientColor }}
>
<div style={{ position: 'relative' }}>
{showSidesGradients &&
isShowStartGradient &&
this._renderStartGradient()}
{this._renderLeftControl()}
{this._renderSlides()}
{this._renderRightControl()}
{showSidesGradients && isShowEndGradient && this._renderEndGradient()}
</div>
{!hideDots && this._renderDots()}
</div>
);
}
componentWillUnmount() {
this._setAutoplayTimer(false);
}
}
export default CarouselWIP;