From 4d17609ed417167537344559fcb66c5712dae38e Mon Sep 17 00:00:00 2001 From: Maxim Slipenko Date: Mon, 30 Jan 2023 20:46:05 +0300 Subject: [PATCH 01/29] add BoxCarousel --- src/BoxCarousel/BoxCarousel.js | 162 +++++++++++++ src/BoxCarousel/BoxCarousel.stories.js | 89 ++++++++ src/BoxCarousel/components/Slide.js | 97 ++++++++ src/BoxCarousel/components/Swiper.js | 8 + .../components/autoplay/AutoPlayButton.js | 134 +++++++++++ .../components/autoplay/useAutoPlay.js | 166 ++++++++++++++ .../components/navigation/Arrow.js | 44 ++++ .../components/navigation/constants.js | 6 + .../components/pagination/Bullet.js | 19 ++ .../components/pagination/Bullets.js | 117 ++++++++++ .../components/pagination/Fraction.js | 19 ++ .../pagination/LabelsAndThumbnails.js | 68 ++++++ .../components/pagination/Pagination.js | 33 +++ .../components/pagination/Progress.js | 23 ++ .../components/pagination/constants.js | 33 +++ .../components/pagination/hooks.js | 50 ++++ .../components/pagination/index.js | 2 + .../components/pagination/utils.js | 93 ++++++++ .../BoxCarouselDataContextContext.js | 7 + .../BoxCarouselDataProvider.js | 3 + .../contexts/BoxCarouselData/index.js | 3 + .../BoxCarouselData/useBoxCarouselData.js | 6 + src/BoxCarousel/hooks/index.js | 6 + .../hooks/useAutoPlayHoverCallbacks.js | 25 ++ src/BoxCarousel/hooks/useAutoPlayModule.js | 8 + src/BoxCarousel/hooks/useCSS.js | 45 ++++ src/BoxCarousel/hooks/useNavigationModule.js | 34 +++ src/BoxCarousel/hooks/usePaginationModule.js | 16 ++ src/BoxCarousel/hooks/useSubscribe.js | 13 ++ src/BoxCarousel/index.js | 1 + src/BoxCarousel/props/index.js | 3 + src/BoxCarousel/props/overrides.js | 216 ++++++++++++++++++ src/BoxCarousel/props/propsDefault.js | 20 ++ src/BoxCarousel/props/propsInfo.js | 202 ++++++++++++++++ src/BoxCarousel/utils/EventEmitter.js | 29 +++ src/BoxCarousel/utils/convertCssTimingToMs.js | 8 + src/BoxCarousel/utils/getModules.js | 35 +++ src/BoxCarousel/utils/index.js | 3 + 38 files changed, 1846 insertions(+) create mode 100644 src/BoxCarousel/BoxCarousel.js create mode 100644 src/BoxCarousel/BoxCarousel.stories.js create mode 100644 src/BoxCarousel/components/Slide.js create mode 100644 src/BoxCarousel/components/Swiper.js create mode 100644 src/BoxCarousel/components/autoplay/AutoPlayButton.js create mode 100644 src/BoxCarousel/components/autoplay/useAutoPlay.js create mode 100644 src/BoxCarousel/components/navigation/Arrow.js create mode 100644 src/BoxCarousel/components/navigation/constants.js create mode 100644 src/BoxCarousel/components/pagination/Bullet.js create mode 100644 src/BoxCarousel/components/pagination/Bullets.js create mode 100644 src/BoxCarousel/components/pagination/Fraction.js create mode 100644 src/BoxCarousel/components/pagination/LabelsAndThumbnails.js create mode 100644 src/BoxCarousel/components/pagination/Pagination.js create mode 100644 src/BoxCarousel/components/pagination/Progress.js create mode 100644 src/BoxCarousel/components/pagination/constants.js create mode 100644 src/BoxCarousel/components/pagination/hooks.js create mode 100644 src/BoxCarousel/components/pagination/index.js create mode 100644 src/BoxCarousel/components/pagination/utils.js create mode 100644 src/BoxCarousel/contexts/BoxCarouselData/BoxCarouselDataContextContext.js create mode 100644 src/BoxCarousel/contexts/BoxCarouselData/BoxCarouselDataProvider.js create mode 100644 src/BoxCarousel/contexts/BoxCarouselData/index.js create mode 100644 src/BoxCarousel/contexts/BoxCarouselData/useBoxCarouselData.js create mode 100644 src/BoxCarousel/hooks/index.js create mode 100644 src/BoxCarousel/hooks/useAutoPlayHoverCallbacks.js create mode 100644 src/BoxCarousel/hooks/useAutoPlayModule.js create mode 100644 src/BoxCarousel/hooks/useCSS.js create mode 100644 src/BoxCarousel/hooks/useNavigationModule.js create mode 100644 src/BoxCarousel/hooks/usePaginationModule.js create mode 100644 src/BoxCarousel/hooks/useSubscribe.js create mode 100644 src/BoxCarousel/index.js create mode 100644 src/BoxCarousel/props/index.js create mode 100644 src/BoxCarousel/props/overrides.js create mode 100644 src/BoxCarousel/props/propsDefault.js create mode 100644 src/BoxCarousel/props/propsInfo.js create mode 100644 src/BoxCarousel/utils/EventEmitter.js create mode 100644 src/BoxCarousel/utils/convertCssTimingToMs.js create mode 100644 src/BoxCarousel/utils/getModules.js create mode 100644 src/BoxCarousel/utils/index.js diff --git a/src/BoxCarousel/BoxCarousel.js b/src/BoxCarousel/BoxCarousel.js new file mode 100644 index 00000000..7b2538fa --- /dev/null +++ b/src/BoxCarousel/BoxCarousel.js @@ -0,0 +1,162 @@ +import React, { useEffect, useState } from 'react'; +import { useOverrides } from '@quarkly/components'; +import { Box } from '@quarkly/widgets'; + +import Swiper from './components/Swiper'; +import Slide from './components/Slide'; +import { BoxCarouselDataProvider } from './contexts/BoxCarouselData'; + +import { + useCSS, + useAutoPlayModule, + usePaginationModule, + useNavigationModule, + useAutoPlayHoverCallbacks, +} from './hooks'; + +import { isPaginationIn, isProgress } from './components/pagination/constants'; +import { navigationType } from './components/navigation/constants'; + +import { getModules, convertCssTimingToMs } from './utils'; +import { propInfo, defaultProps, overrides } from './props'; + +const BoxCarousel = ({ + effect, + slidesProp, + slidesAs, + showArrows, + showPagination, + draggable, + infinityMode, + keyboardControl, + showAutoPlayButton, + autoPlay: autoPlayEnabled, + autoPlaySpeed, + autoPlayHoverPause, + ...props +}) => { + useCSS(); + const { override, ChildPlaceholder, rest } = useOverrides( + props, + overrides, + {} + ); + + const [swiper, setSwiper] = useState(null); + + const slidesCount = parseInt(slidesProp, 10); + + const [navigation, Navigation] = useNavigationModule(showArrows); + const [Pagination] = usePaginationModule(showPagination); + const [useAutoPlay, AutoPlay] = useAutoPlayModule(); + + const autoPlaySpeedInt = convertCssTimingToMs(autoPlaySpeed); + const autoplay = useAutoPlay(autoPlayEnabled, swiper, autoPlaySpeedInt); + + const hoverCallbacks = useAutoPlayHoverCallbacks( + autoplay, + autoPlayHoverPause + ); + + // HACK: for update swiper on props change + const key = `${infinityMode}${showArrows}${draggable}${keyboardControl}${effect}`; + + return ( + + + + {showArrows !== navigationType.none && ( + + + + )} + setSwiper(sw)} + navigation={navigation} + modules={getModules({ + effect, + showArrows, + keyboardControl, + })} + allowTouchMove={draggable} + loop={infinityMode} + keyboard={keyboardControl} + height="400px" + > + {[...Array(slidesCount)].map((_, index) => ( + + ))} + + {showArrows !== navigationType.none && ( + + + + )} + {isProgress(showPagination) && } + + + {!isProgress(showPagination) && } + + + + + ); +}; + +Object.assign(BoxCarousel, { + title: 'BoxCarousel', + description: { + en: + 'This component is a counter that increases or decreases to a certain value', + ru: + 'Компонент представляет из себя счетчик, который увеличивается или уменьшается до определенного значения', + }, + propInfo, + defaultProps, +}); + +export default BoxCarousel; diff --git a/src/BoxCarousel/BoxCarousel.stories.js b/src/BoxCarousel/BoxCarousel.stories.js new file mode 100644 index 00000000..841756fa --- /dev/null +++ b/src/BoxCarousel/BoxCarousel.stories.js @@ -0,0 +1,89 @@ +import React from 'react'; +import { Override } from '@quarkly/components'; +import { Box, Text, Button } from '@quarkly/widgets'; +import BoxCarousel from './BoxCarousel'; +import { argTypes } from '../modules'; + +export default { + title: 'BoxCarousel', + component: BoxCarousel, + args: BoxCarousel.defaultProps, + argTypes: argTypes(BoxCarousel.propInfo, BoxCarousel.defaultProps), +}; + +export const StoryDefault = (props) => ; + +StoryDefault.storyName = 'Default'; + +export const StoryWithContent = (props) => ( + + + + + Tour 1 + + + Mountains + + + + + + Curabitur lobortis id lorem id bibendum. Ut id + consectetur magna. Quisque volutpat augue enim, pulvinar + lobortis nibh lacinia at + + + + + + + + + + + + + +); + +StoryWithContent.storyName = 'WithContent'; diff --git a/src/BoxCarousel/components/Slide.js b/src/BoxCarousel/components/Slide.js new file mode 100644 index 00000000..3ce52dbb --- /dev/null +++ b/src/BoxCarousel/components/Slide.js @@ -0,0 +1,97 @@ +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { + useConstructorMode, + Placeholder, + LinkBox, + Box, +} from '@quarkly/widgets'; + +import { useBoxCarouselData } from '../contexts/BoxCarouselData'; +import { isEmptyChildren } from '../../utils'; + +const containerComponents = Object.freeze({ + linkbox: LinkBox, + box: Box, +}); + +const Slide = ({ index, swiper, slidesAs, className, ...props }) => { + const ref = useRef(null); + const { override, ChildPlaceholder } = useBoxCarouselData(); + const [slideClasses, setSlideClasses] = useState(['swiper-slide']); + + const updateClasses = useCallback((_s, el, classNames) => { + if (el === ref.current) { + setSlideClasses(classNames.split(' ')); + } + }, []); + + useLayoutEffect(() => { + swiper.on('_slideClass', updateClasses); + return () => { + if (!swiper) return; + swiper.off('_slideClass', updateClasses); + }; + }, [updateClasses, swiper]); + + useLayoutEffect(() => { + if (ref.current) { + setSlideClasses(ref.current.className.split(' ')); + } + }, []); + + const isDuplicateActive = slideClasses.includes( + 'swiper-slide-duplicate-active' + ); + const isActive = slideClasses.includes('swiper-slide-active'); + const isDuplicate = slideClasses.includes('swiper-slide-duplicate'); + + const uniqueOverride = `Slide ${props['data-swiper-slide-index']}`; + + const mode = useConstructorMode(); + + const hideSlide = + !(mode !== 'constructor') && + (isDuplicateActive || (isDuplicate && !isActive)); + + const clearOverride = hideSlide && { + 'data-qoverride': undefined, + 'data-child-placeholder': undefined, + }; + + const ContainerComponent = containerComponents[slidesAs] ?? Box; + + return ( + + {isEmptyChildren(override(uniqueOverride).children) && ( + + )} + + ); +}; + +// {!hideSlide && } + +Object.assign(Slide, { + displayName: 'SwiperSlide', + title: 'BoxCarousel', + description: { + en: + 'This component is a counter that increases or decreases to a certain value', + ru: + 'Компонент представляет из себя счетчик, который увеличивается или уменьшается до определенного значения', + }, + propInfo: {}, + defaultProps: {}, +}); + +export default Slide; diff --git a/src/BoxCarousel/components/Swiper.js b/src/BoxCarousel/components/Swiper.js new file mode 100644 index 00000000..464e95ea --- /dev/null +++ b/src/BoxCarousel/components/Swiper.js @@ -0,0 +1,8 @@ +import { Swiper } from 'swiper/react'; +import atomize from '@quarkly/atomize'; + +const AtomizedSwiper = atomize(Swiper)(); + +AtomizedSwiper.displayName = 'Swiper'; + +export default AtomizedSwiper; diff --git a/src/BoxCarousel/components/autoplay/AutoPlayButton.js b/src/BoxCarousel/components/autoplay/AutoPlayButton.js new file mode 100644 index 00000000..bff58a36 --- /dev/null +++ b/src/BoxCarousel/components/autoplay/AutoPlayButton.js @@ -0,0 +1,134 @@ +import React, { useRef, useCallback, useState, useEffect } from 'react'; +import { Box, Button, Icon } from '@quarkly/widgets'; +import atomized from '@quarkly/atomize'; +import { useBoxCarouselData } from '../../contexts/BoxCarouselData'; +import useSubscribe from '../../hooks/useSubscribe'; + +const Svg = atomized.svg({ + useAliases: false, +}); +const Circle = atomized.circle({ + useAliases: false, +}); + +const AutoPlayButton = ({ autoplay, autoPlayEnabled, show }) => { + const { override } = useBoxCarouselData(); + const ref = useRef(); + + const animStateRef = useRef(); + + const animate = useCallback((remaining, isPaused, percent) => { + if (!ref.current) return; + + if (!isPaused) { + Object.assign(ref.current.style, { + transition: 'none', + strokeDashoffset: percent * 283, + }); + + setTimeout(() => { + Object.assign(ref.current.style, { + transition: `stroke-dashoffset ${remaining}ms linear`, + strokeDashoffset: '0', + }); + animStateRef.current = '283'; + }, 0); + } else { + animStateRef.current = percent * 283; + + Object.assign(ref.current.style, { + transition: null, + strokeDashoffset: animStateRef.current, + }); + } + }, []); + + const [isPaused, setPaused] = useState(false); + + const pauseHandle = useCallback(() => { + setPaused(true); + }, []); + + const resumeHandle = useCallback(() => { + setPaused(false); + }, []); + + const onClick = useCallback(() => { + if (autoplay.isPaused) { + autoplay.resume(); + } else { + autoplay.pause(); + } + }, [autoplay]); + + useSubscribe(autoplay, 'animate', animate); + useSubscribe(autoplay, 'pause', pauseHandle); + useSubscribe(autoplay, 'resume', resumeHandle); + + useEffect(() => { + if (autoPlayEnabled && show) { + animate(...autoplay.getAnimateOptions()); + } + }, [animate, autoPlayEnabled, autoplay, show]); + + if (!show || !autoPlayEnabled) return null; + + return ( + + + + ); +}; + +export default AutoPlayButton; diff --git a/src/BoxCarousel/components/autoplay/useAutoPlay.js b/src/BoxCarousel/components/autoplay/useAutoPlay.js new file mode 100644 index 00000000..c1d437ad --- /dev/null +++ b/src/BoxCarousel/components/autoplay/useAutoPlay.js @@ -0,0 +1,166 @@ +import { useRef, useLayoutEffect } from 'react'; +import { useConstructorMode } from '@quarkly/widgets'; +import EventEmitter from '../../utils/EventEmitter'; + +class AutoPlay extends EventEmitter { + constructor(options) { + super(); + + this._enabled = false; + + this._isPaused = true; + this._isInteractionPause = false; + this._timeout = null; + this._start = null; + + this.init(options); + } + + get remaining() { + return this._remaining; + } + + get delay() { + return this._delay; + } + + get isPaused() { + return this._isPaused; + } + + get enabled() { + return this._enabled; + } + + init({ remaining, delay, swiper, enabled }) { + this._remaining = remaining; + this._delay = delay; + this._swiper = swiper; + this._enabled = enabled; + + if (this._enabled) { + clearTimeout(this._timeout); + this._start = null; + } + } + + pause(reset, isInteraction = false) { + this._isInteractionPause = isInteraction; + + if (this._isPaused === true) return; + + this._isPaused = true; + this.emit('pause'); + clearTimeout(this._timeout); + if (reset) { + this._remaining = this.delay; + } else { + this._remaining -= Date.now() - this._start; + } + this.emit('animate', ...this.getAnimateOptions()); + } + + resume() { + if (!this._enabled) return; + + this._isPaused = false; + this._isInteractionPause = null; + this._start = Date.now(); + if (!this._swiper.params.loop && this._swiper.isEnd) { + this.pause(true); + return; + } + + this.emit('resume'); + + this.emit('animate', ...this.getAnimateOptions()); + + const run = () => { + this._swiper.loopFix(); + this._swiper.slideNext(this._swiper.params.speed, true, true); + }; + + clearTimeout(this._timeout); + this._timeout = setTimeout(() => { + this._remaining = this._delay; + + run(); + this.resume(); + }, this.remaining); + } + + getAnimateOptions() { + if (this._isPaused) { + return [this.remaining, this.isPaused, this.remaining / this.delay]; + } + + const remaining = this.remaining - (new Date() - this._start); + return [remaining, this.isPaused, remaining / this.delay]; + } + + hoverResume() { + if (this._isInteractionPause) return; + + this.resume(); + } + + hoverPause() { + if (this._isInteractionPause) return; + + this.pause(); + } +} + +const useAutoPlay = (autoPlay, swiper, delay) => { + const autoPlayRef = useRef( + new AutoPlay({ + remaining: delay, + delay, + swiper, + }) + ); + const mode = useConstructorMode(); + + useLayoutEffect(() => { + if (!swiper) return; + + autoPlayRef.current.init({ + remaining: delay, + swiper, + delay, + enabled: autoPlay, + }); + + if (autoPlay && mode !== 'constructor') { + setTimeout(() => autoPlayRef.current.resume(), 0); + } else { + autoPlayRef.current.pause(true); + } + }, [swiper, autoPlay, delay, mode]); + + useLayoutEffect(() => { + if (!swiper) return; + + const sliderFirstMove = () => { + autoPlayRef.current.pause(false, true); + }; + + const beforeTransitionStart = (_s, speed, internal) => { + if (!internal) { + autoPlayRef.current.pause(false, true); + } + }; + + swiper.on('sliderFirstMove', sliderFirstMove); + swiper.on('beforeTransitionStart', beforeTransitionStart); + + return () => { + swiper.off('sliderFirstMove', sliderFirstMove); + swiper.off('beforeTransitionStart', beforeTransitionStart); + }; + }, [swiper, autoPlay]); + + return autoPlayRef.current; +}; + +export default useAutoPlay; diff --git a/src/BoxCarousel/components/navigation/Arrow.js b/src/BoxCarousel/components/navigation/Arrow.js new file mode 100644 index 00000000..27f1767f --- /dev/null +++ b/src/BoxCarousel/components/navigation/Arrow.js @@ -0,0 +1,44 @@ +import { Icon, Button } from '@quarkly/widgets'; +import React, { forwardRef, useCallback, useRef } from 'react'; +import { useBoxCarouselData } from '../../contexts/BoxCarouselData'; +import { useSubscribe } from '../../hooks'; + +// eslint-disable-next-line react/display-name +const Arrow = forwardRef(({ direction, ...props }, r) => { + const ref = useRef(); + + const { override, swiper } = useBoxCarouselData(); + + const onDestroy = useCallback(() => { + const arrow = document.querySelector( + `[data-swiper-arrow="${direction}"]` + ); + + if (arrow) { + arrow.disabled = false; + } + }, []); + + useSubscribe(swiper, 'destroy', onDestroy); + + return ( + + ); +}); + +Arrow.displayName = 'Arrow'; + +export default Arrow; diff --git a/src/BoxCarousel/components/navigation/constants.js b/src/BoxCarousel/components/navigation/constants.js new file mode 100644 index 00000000..53abb7e3 --- /dev/null +++ b/src/BoxCarousel/components/navigation/constants.js @@ -0,0 +1,6 @@ +// eslint-disable-next-line import/prefer-default-export +export const navigationType = Object.freeze({ + none: 'none', + arrowsin: 'arrowsin', + arrowsout: 'arrowsout', +}); diff --git a/src/BoxCarousel/components/pagination/Bullet.js b/src/BoxCarousel/components/pagination/Bullet.js new file mode 100644 index 00000000..ba04ff3a --- /dev/null +++ b/src/BoxCarousel/components/pagination/Bullet.js @@ -0,0 +1,19 @@ +import { Button } from '@quarkly/widgets'; +import React from 'react'; +import { useBoxCarouselData } from '../../contexts/BoxCarouselData'; +import { usePageButtonProps } from './utils'; + +const Bullet = ({ index, ...props }) => { + const { override } = useBoxCarouselData(); + const { isCurrent, clickHandler } = usePageButtonProps(index); + + return ( +