UNPKG

react-grid-carousel

Version:

React resposive carousel component w/ grid layout

445 lines (401 loc) 12.3 kB
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react' import PropTypes from 'prop-types' import styled from 'styled-components' import ArrowButton from './ArrowButton' import Dot from './Dot' import smoothscroll from 'smoothscroll-polyfill' import useResponsiveLayout from '../hooks/responsiveLayoutHook' import { addResizeHandler, removeResizeHandler } from '../utils/resizeListener' const Container = styled.div` position: relative; ` const RailWrapper = styled.div` overflow: hidden; margin: ${({ showDots }) => (showDots ? '0 20px 15px 20px' : '0 20px')}; @media screen and (max-width: ${({ mobileBreakpoint }) => mobileBreakpoint}px) { overflow-x: auto; margin: 0; scroll-snap-type: ${({ scrollSnap }) => (scrollSnap ? 'x mandatory' : '')}; scrollbar-width: none; &::-webkit-scrollbar { display: none; } } ` const Rail = styled.div` display: grid; grid-column-gap: ${({ gap }) => `${gap}px`}; position: relative; transition: transform 0.5s cubic-bezier(0.2, 1, 0.3, 1) 0s; grid-template-columns: ${({ page }) => `repeat(${page}, 100%)`}; transform: ${({ currentPage, gap }) => `translateX(calc(${-100 * currentPage}% - ${gap * currentPage}px))`}; @media screen and (max-width: ${({ mobileBreakpoint }) => mobileBreakpoint}px) { padding-left: ${({ gap }) => `${gap}px`}; grid-template-columns: ${({ page }) => `repeat(${page}, 90%)`}; grid-column-gap: ${({ cols, rows, gap }) => `calc(${(cols * rows - 1) * 90}% + ${cols * rows * gap}px)`}; transform: translateX(0); } ` const ItemSet = styled.div` display: grid; grid-template-columns: ${({ cols }) => `repeat(${cols}, 1fr)`}; grid-template-rows: ${({ rows }) => `repeat(${rows}, 1fr)`}; grid-gap: ${({ gap }) => `${gap}px`}; @media screen and (max-width: ${({ mobileBreakpoint }) => mobileBreakpoint}px) { grid-template-columns: ${({ cols, rows }) => `repeat(${cols * rows}, 100%)`}; grid-template-rows: 1fr; &:last-of-type > ${/* sc-sel */ Item}:last-of-type { padding-right: ${({ gap }) => `${gap}px`}; margin-right: ${({ gap }) => `-${gap}px`}; } } ` const Dots = styled.div` position: absolute; display: flex; align-items: center; justify-content: center; bottom: -12px; height: 10px; width: 100%; line-height: 10px; text-align: center; @media screen and (max-width: ${({ mobileBreakpoint }) => mobileBreakpoint}px) { display: none; } ` const Item = styled.div` scroll-snap-align: ${({ scrollSnap }) => (scrollSnap ? 'center' : '')}; ` const CAROUSEL_ITEM = 'CAROUSEL_ITEM' const Carousel = ({ cols: colsProp = 1, rows: rowsProp = 1, gap: gapProp = 10, loop: loopProp = false, scrollSnap = true, hideArrow = false, showDots = false, autoplay: autoplayProp, dotColorActive = '#795548', dotColorInactive = '#ccc', responsiveLayout, mobileBreakpoint = 767, arrowLeft, arrowRight, dot, containerClassName = '', containerStyle = {}, children }) => { const [currentPage, setCurrentPage] = useState(0) const [isHover, setIsHover] = useState(false) const [isTouch, setIsTouch] = useState(false) const [cols, setCols] = useState(colsProp) const [rows, setRows] = useState(rowsProp) const [gap, setGap] = useState(0) const [loop, setLoop] = useState(loopProp) const [autoplay, setAutoplay] = useState(autoplayProp) const [railWrapperWidth, setRailWrapperWidth] = useState(0) const [hasSetResizeHandler, setHasSetResizeHandler] = useState(false) const railWrapperRef = useRef(null) const autoplayIntervalRef = useRef(null) const breakpointSetting = useResponsiveLayout(responsiveLayout) const randomKey = useMemo(() => `${Math.random()}-${Math.random()}`, []) useEffect(() => { smoothscroll.polyfill() }, []) useEffect(() => { const { cols, rows, gap, loop, autoplay } = breakpointSetting || {} setCols(cols || colsProp) setRows(rows || rowsProp) setGap(parseGap(gap || gapProp)) setLoop(loop || loopProp) setAutoplay(autoplay || autoplayProp) setCurrentPage(0) }, [ breakpointSetting, colsProp, rowsProp, gapProp, loopProp, autoplayProp, parseGap ]) const handleRailWrapperResize = useCallback(() => { railWrapperRef.current && setRailWrapperWidth(railWrapperRef.current.offsetWidth) }, [railWrapperRef]) const setResizeHandler = useCallback(() => { addResizeHandler(`gapCalculator-${randomKey}`, handleRailWrapperResize) setHasSetResizeHandler(true) }, [randomKey, handleRailWrapperResize]) const rmResizeHandler = useCallback(() => { removeResizeHandler(`gapCalculator-${randomKey}`) setHasSetResizeHandler(false) }, [randomKey]) const parseGap = useCallback( gap => { let parsed = gap let shouldSetResizeHandler = false if (typeof gap !== 'number') { switch (/\D*$/.exec(gap)[0]) { case 'px': { parsed = +gap.replace('px', '') break } case '%': { let wrapperWidth = railWrapperWidth || railWrapperRef.current ? railWrapperRef.current.offsetWidth : 0 parsed = (wrapperWidth * gap.replace('%', '')) / 100 shouldSetResizeHandler = true break } default: { parsed = 0 console.error( `Doesn't support the provided measurement unit: ${gap}` ) } } } shouldSetResizeHandler && !hasSetResizeHandler && setResizeHandler() !shouldSetResizeHandler && hasSetResizeHandler && rmResizeHandler() return parsed }, [ railWrapperWidth, railWrapperRef, hasSetResizeHandler, setResizeHandler, rmResizeHandler ] ) const itemList = useMemo( () => React.Children.toArray(children).filter( child => child.type.displayName === CAROUSEL_ITEM ), [children] ) const itemAmountPerSet = cols * rows const itemSetList = useMemo( () => itemList.reduce((result, item, i) => { const itemComponent = ( <Item key={i} scrollSnap={scrollSnap}> {item} </Item> ) if (i % itemAmountPerSet === 0) { result.push([itemComponent]) } else { result[result.length - 1].push(itemComponent) } return result }, []), [itemList, itemAmountPerSet, scrollSnap] ) const page = Math.ceil(itemList.length / itemAmountPerSet) const handlePrev = useCallback(() => { setCurrentPage(p => { const prevPage = p - 1 if (loop && prevPage < 0) { return page - 1 } return prevPage }) }, [loop, page]) const handleNext = useCallback( (isMobile = false) => { const railWrapper = railWrapperRef.current if (isMobile && railWrapper) { if (!scrollSnap) { return } const { scrollLeft, offsetWidth, scrollWidth } = railWrapper railWrapper.scrollBy({ top: 0, left: loop && scrollLeft + offsetWidth >= scrollWidth ? -scrollLeft : scrollLeft === 0 ? gap + (offsetWidth - gap) * 0.9 - (offsetWidth * 0.1 - gap * 1.1) / 2 : (offsetWidth - gap) * 0.9 + gap, behavior: 'smooth' }) } else { setCurrentPage(p => { const nextPage = p + 1 if (nextPage >= page) { return loop ? 0 : p } return nextPage }) } }, [loop, page, gap, railWrapperRef, scrollSnap] ) const startAutoplayInterval = useCallback(() => { if (autoplayIntervalRef.current === null && typeof autoplay === 'number') { autoplayIntervalRef.current = setInterval(() => { handleNext(window.innerWidth <= mobileBreakpoint) }, autoplay) } }, [autoplay, autoplayIntervalRef, handleNext, mobileBreakpoint]) useEffect(() => { startAutoplayInterval() return () => { if (autoplayIntervalRef.current !== null) { clearInterval(autoplayIntervalRef.current) autoplayIntervalRef.current = null } } }, [startAutoplayInterval, autoplayIntervalRef]) useEffect(() => { if (isHover || isTouch) { clearInterval(autoplayIntervalRef.current) autoplayIntervalRef.current = null } else { startAutoplayInterval() } }, [isHover, isTouch, autoplayIntervalRef, startAutoplayInterval]) const turnToPage = useCallback(page => { setCurrentPage(page) }, []) const handleHover = useCallback(() => { setIsHover(hover => !hover) }, []) const handleTouch = useCallback(() => { setIsTouch(touch => !touch) }, []) return ( <Container onMouseEnter={handleHover} onMouseLeave={handleHover} onTouchStart={handleTouch} onTouchEnd={handleTouch} className={containerClassName} style={containerStyle} > <ArrowButton type="prev" mobileBreakpoint={mobileBreakpoint} hidden={hideArrow || (!loop && currentPage <= 0)} CustomBtn={arrowLeft} onClick={handlePrev} /> <RailWrapper mobileBreakpoint={mobileBreakpoint} scrollSnap={scrollSnap} showDots={showDots} ref={railWrapperRef} > <Rail cols={cols} rows={rows} page={page} gap={gap} currentPage={currentPage} mobileBreakpoint={mobileBreakpoint} > {itemSetList.map((set, i) => ( <ItemSet key={i} cols={cols} rows={rows} gap={gap} mobileBreakpoint={mobileBreakpoint} > {set} </ItemSet> ))} </Rail> </RailWrapper> {showDots && ( <Dots mobileBreakpoint={mobileBreakpoint}> {[...Array(page)].map((_, i) => ( <Dot key={i} index={i} isActive={i === currentPage} dotColorInactive={dotColorInactive} dotColorActive={dotColorActive} dot={dot} onClick={turnToPage} /> ))} </Dots> )} <ArrowButton type="next" mobileBreakpoint={mobileBreakpoint} hidden={hideArrow || (!loop && currentPage === page - 1)} CustomBtn={arrowRight} onClick={handleNext.bind(null, false)} /> </Container> ) } const positiveNumberValidator = (props, propName, componentName) => { const prop = props[propName] if ((prop !== undefined && typeof prop !== 'number') || prop <= 0) { return new Error( `Invalid prop \`${propName}\`: ${props[propName]} supplied to \`${componentName}\`. expected positive \`number\`` ) } } Carousel.propTypes = { cols: positiveNumberValidator, rows: positiveNumberValidator, gap: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), loop: PropTypes.bool, scrollSnap: PropTypes.bool, hideArrow: PropTypes.bool, showDots: PropTypes.bool, autoplay: PropTypes.number, dotColorActive: PropTypes.string, dotColorInactive: PropTypes.string, responsiveLayout: PropTypes.arrayOf( PropTypes.shape({ breakpoint: PropTypes.number.isRequired, cols: positiveNumberValidator, rows: positiveNumberValidator, gap: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), loop: PropTypes.bool, autoplay: PropTypes.number }) ), mobileBreakpoint: PropTypes.number, arrowLeft: PropTypes.oneOfType([ PropTypes.node, PropTypes.element, PropTypes.elementType ]), arrowRight: PropTypes.oneOfType([ PropTypes.node, PropTypes.element, PropTypes.elementType ]), dot: PropTypes.oneOfType([ PropTypes.node, PropTypes.element, PropTypes.elementType ]), containerClassName: PropTypes.string, containerStyle: PropTypes.object } Carousel.Item = ({ children }) => children Carousel.Item.displayName = CAROUSEL_ITEM export default Carousel