UNPKG

@wix/design-system

Version:

@wix/design-system

440 lines 20.8 kB
import React, { useState, useEffect, useCallback, useMemo, useImperativeHandle, forwardRef, useRef, } from 'react'; import useEmblaCarousel from 'embla-carousel-react'; import Autoplay from 'embla-carousel-autoplay'; import { ChevronLeftLarge, ChevronRightLarge, ChevronLeftLargeSmall, ChevronRightLargeSmall, ChevronLeftSmall, ChevronRightSmall, } from '@wix/wix-ui-icons-common'; // This is here and not in the test setup because we don't want consumers to need to run it as well import { isTestEnv, registerWindowMatchMedia, registerIntersectionObserver, registerResizeObserver, } from './utils/match-media-register'; import { st, classes, vars } from './Carousel.st.css.js'; import Pagination from './Pagination'; import SliderArrow from './SliderArrow'; import Loader from '../Loader'; import Proportion from '../Proportion'; import { PREDEFINED_RATIOS } from '../Proportion/Proportion.constants'; import { DATA_HOOKS } from './Carousel.constants'; if (isTestEnv && typeof window !== 'undefined' && !window.matchMedia) { registerWindowMatchMedia(); } if (isTestEnv) { registerIntersectionObserver(); registerResizeObserver(); } const AUTOPLAY_SPEED = 3000; const TRANSITION_SPEED = 600; function inferDirection(oldIndex, newIndex, count, isLoop) { if (!isLoop) { return newIndex > oldIndex ? 'next' : 'prev'; } const forward = (newIndex - oldIndex + count) % count; const backward = (oldIndex - newIndex + count) % count; return forward <= backward ? 'next' : 'prev'; } function getAlignDirection(direction) { return direction === 'next' ? 'right' : 'left'; } function clampToEngineLimits(engine, value) { return Math.max(engine.limit.min, Math.min(engine.limit.max, value)); } function normalizeIndex(index, count) { if (count === 0) return 0; const result = index % count; return result < 0 ? result + count : result; } function leftIcon(controlSize) { switch (controlSize) { case 'tiny': return React.createElement(ChevronLeftSmall, null); case 'small': return React.createElement(ChevronLeftLargeSmall, null); default: return React.createElement(ChevronLeftLarge, null); } } function rightIcon(controlSize) { switch (controlSize) { case 'tiny': return React.createElement(ChevronRightSmall, null); case 'small': return React.createElement(ChevronRightLargeSmall, null); default: return React.createElement(ChevronRightLarge, null); } } const Carousel = forwardRef((props, ref) => { const { dataHook, className, images = [], imagesPosition = 'center top', imagesFit = 'contain', children, buttonSkin = 'standard', showControlsShadow = false, infinite = true, autoplay = false, dots = true, variableWidth = false, initialSlideIndex = 0, afterChange, beforeChange, controlsPosition = 'sides', controlsSize = 'medium', controlsStartEnd = 'disabled', gradientColor, gradient = false, slideSpacing, animationDuration, slidingType = 'align-to-start', align: alignProp, startEndOffset: startEndOffsetProp, } = props; // Priority: align > startEndOffset > default align='start'. // When the consumer explicitly sets `align`, it takes full control of slide // positioning and `startEndOffset` is ignored. When only `startEndOffset` // is provided (without `align`), peeking offsets are applied on top of the // default 'start' alignment. const align = alignProp ?? 'start'; const startEndOffset = alignProp !== undefined ? undefined : startEndOffsetProp; const [loadedImages, setLoadedImages] = useState([]); const slideCount = children ? React.Children.count(children) : images.length; const normalizedInitialIndex = normalizeIndex(initialSlideIndex, slideCount); const [selectedIndex, setSelectedIndex] = useState(normalizedInitialIndex); const currentIndexRef = useRef(normalizedInitialIndex); const programmaticTargetRef = useRef(null); const shouldLoop = infinite && slideCount > 1; const peekOffsetTargetRef = useRef(null); const peekOffsetDirectionRef = useRef(null); const [prevBtnDisabled, setPrevBtnDisabled] = useState(!shouldLoop && normalizedInitialIndex === 0); const [nextBtnDisabled, setNextBtnDisabled] = useState(!shouldLoop && normalizedInitialIndex === slideCount - 1); const [slidesInView, setSlidesInView] = useState( // In test env, IntersectionObserver is a no-op so embla.slidesInView() // stays empty. Seed with the initial slide so aria-hidden is correct. isTestEnv ? [normalizedInitialIndex] : []); const emblaDuration = animationDuration ? Math.min(Math.max(animationDuration / 25, 20), 60) : TRANSITION_SPEED / 25; const slidesToScroll = useMemo(() => { switch (slidingType) { case 'align-to-start': return undefined; case 'reveal-one': return 1; case 'reveal-chunk': return 'auto'; default: return undefined; } }, [slidingType]); const containScroll = useMemo(() => { if (variableWidth && shouldLoop) { return undefined; } return 'trimSnaps'; }, [shouldLoop, variableWidth]); const emblaOptions = useMemo(() => ({ loop: shouldLoop, startIndex: normalizedInitialIndex, duration: emblaDuration, align, slidesToScroll, containScroll, skipSnaps: false, }), [ align, containScroll, emblaDuration, normalizedInitialIndex, shouldLoop, slidesToScroll, ]); const [emblaRef, emblaApi] = useEmblaCarousel(emblaOptions, autoplay ? [ Autoplay({ delay: AUTOPLAY_SPEED, stopOnInteraction: false, stopOnMouseEnter: true, }), ] : []); // Slides use padding-left for spacing, so at each snap the gap sits at the // left viewport edge while the right edge is flush. Peek right needs the // extra gap added; peek left does not (the gap is already visible). const calculatePeekPosition = useCallback((baseX, direction) => { if (!startEndOffset || startEndOffset <= 0) { return baseX; } const alignDir = getAlignDirection(direction); const gap = slideSpacing ?? 0; return alignDir === 'right' ? baseX - (startEndOffset + gap) : baseX + startEndOffset; }, [startEndOffset, slideSpacing]); const transitionSlide = useCallback((fromIndex, toIndex) => { if (fromIndex === toIndex) return false; beforeChange?.(fromIndex, toIndex); setSelectedIndex(toIndex); currentIndexRef.current = toIndex; return true; }, [beforeChange]); const onSelect = useCallback((api) => { if (!api) return; const emblaIndex = api.selectedScrollSnap(); const pendingTarget = programmaticTargetRef.current; const newIndex = pendingTarget !== null ? pendingTarget : emblaIndex; const oldIndex = currentIndexRef.current; programmaticTargetRef.current = null; if (!shouldLoop) { let prevDisabled = !api.canScrollPrev(); let nextDisabled = !api.canScrollNext(); // When startEndOffset is active, the peek adjustment may push the // scroll position beyond the engine limit at snaps that are close to // the boundary. After clamping, the visual position is identical to // the true last/first snap, so we treat the carousel as "at the end" // and disable the corresponding button early. if (startEndOffset && startEndOffset > 0) { const engine = api.internalEngine(); const currentSnap = engine.scrollSnaps[emblaIndex]; if (!nextDisabled) { const nextPeek = calculatePeekPosition(currentSnap, 'next'); if (nextPeek < engine.limit.min) { nextDisabled = true; } } if (!prevDisabled) { const prevPeek = calculatePeekPosition(currentSnap, 'prev'); if (prevPeek > engine.limit.max) { prevDisabled = true; } } } setPrevBtnDisabled(prevDisabled); setNextBtnDisabled(nextDisabled); } else { setPrevBtnDisabled(false); setNextBtnDisabled(false); } if (transitionSlide(oldIndex, newIndex)) { // Apply startEndOffset for drag/touch scrolls (programmatic handled in performScroll) if (pendingTarget === null && startEndOffset && startEndOffset > 0 && !isTestEnv) { const dir = inferDirection(oldIndex, newIndex, slideCount, shouldLoop); const engine = api.internalEngine(); let adjusted = calculatePeekPosition(engine.target.get(), dir); if (!shouldLoop) { adjusted = clampToEngineLimits(engine, adjusted); } engine.target.set(adjusted); peekOffsetTargetRef.current = adjusted; peekOffsetDirectionRef.current = dir; } } }, [ transitionSlide, shouldLoop, startEndOffset, slideCount, calculatePeekPosition, ]); const onSettle = useCallback(() => { afterChange?.(currentIndexRef.current); }, [afterChange]); useEffect(() => { if (!emblaApi) return; emblaApi.on('select', onSelect); return () => { emblaApi.off('select', onSelect); }; }, [emblaApi, onSelect]); useEffect(() => { if (!emblaApi) return; emblaApi.on('settle', onSettle); return () => { emblaApi.off('settle', onSettle); }; }, [emblaApi, onSettle]); // On click (no drag), Embla resets target to snap — restore our offset. useEffect(() => { if (!emblaApi || !startEndOffset || startEndOffset <= 0 || isTestEnv) return; const onPointerUp = () => { const expected = peekOffsetTargetRef.current; if (expected === null) return; emblaApi.internalEngine().target.set(expected); }; emblaApi.on('pointerUp', onPointerUp); return () => { emblaApi.off('pointerUp', onPointerUp); }; }, [emblaApi, startEndOffset]); // Track which slides are currently visible in the viewport for a11y. // In test env IntersectionObserver is a no-op, so embla.slidesInView() // always returns []. We skip this effect and rely on the seeded initial // state + syncStateForTests to keep slidesInView accurate. useEffect(() => { if (!emblaApi || isTestEnv) return; const updateSlidesInView = () => { setSlidesInView(emblaApi.slidesInView()); }; emblaApi.on('slidesInView', updateSlidesInView); // Initial sync updateSlidesInView(); return () => { emblaApi.off('slidesInView', updateSlidesInView); }; }, [emblaApi]); const syncStateForTests = useCallback((newIndex) => { transitionSlide(currentIndexRef.current, newIndex); // In tests there is no animation so the settle event won't fire. // Fire afterChange synchronously to match production behaviour. afterChange?.(newIndex); if (!shouldLoop) { setPrevBtnDisabled(newIndex === 0); setNextBtnDisabled(newIndex === slideCount - 1); } // IntersectionObserver is a no-op in tests, so keep slidesInView in sync. setSlidesInView([newIndex]); }, [transitionSlide, afterChange, shouldLoop, slideCount]); // Nudges Embla's internal animation target to integrate startEndOffset // into the scroll animation as a single smooth motion. const performScroll = useCallback((targetIndex, direction) => { const hasOffset = startEndOffset !== undefined && startEndOffset > 0 && !!direction; if (hasOffset && !isTestEnv && emblaApi) { emblaApi.scrollTo(targetIndex, false); const engine = emblaApi.internalEngine(); let adjustedTarget = calculatePeekPosition(engine.target.get(), direction); if (!shouldLoop) { adjustedTarget = clampToEngineLimits(engine, adjustedTarget); } engine.target.set(adjustedTarget); const snapTarget = engine.scrollSnaps[targetIndex]; if (Math.abs(adjustedTarget - snapTarget) > 0.5) { peekOffsetTargetRef.current = adjustedTarget; peekOffsetDirectionRef.current = direction; } else { peekOffsetTargetRef.current = null; peekOffsetDirectionRef.current = null; } } else { emblaApi?.scrollTo(targetIndex, isTestEnv); peekOffsetTargetRef.current = null; peekOffsetDirectionRef.current = null; } if (isTestEnv) { syncStateForTests(targetIndex); } }, [ emblaApi, startEndOffset, shouldLoop, calculatePeekPosition, syncStateForTests, ]); const scrollTo = useCallback((index) => { programmaticTargetRef.current = index; const currentIndex = currentIndexRef.current; const direction = index > currentIndex ? 'next' : index < currentIndex ? 'prev' : undefined; performScroll(index, direction); }, [performScroll]); useImperativeHandle(ref, () => ({ slideTo: (index) => { scrollTo(index); }, leftIconByControlSize: leftIcon, rightIconByControlSize: rightIcon, }), [scrollTo]); const onImageLoad = useCallback((src) => { setLoadedImages(prev => [...prev, src]); }, []); const isLoading = useCallback((src) => !loadedImages.includes(src), [loadedImages]); const renderImages = useCallback((imagesList) => { return imagesList.map((image, index) => { const { src, style, ...imageProps } = image; const isInView = slidesInView.includes(index); return (React.createElement("div", { key: `${index}${src}`, className: "embla__slide", "data-index": index, role: "group", "aria-roledescription": "slide", "aria-label": `Slide ${index + 1} of ${imagesList.length}`, "aria-hidden": !isInView, tabIndex: isInView ? undefined : -1 }, React.createElement(Proportion, { aspectRatio: PREDEFINED_RATIOS.landscape }, React.createElement("div", { className: st(classes.imageContainer, { isLoading: isLoading(src), }), "data-hook": DATA_HOOKS.imagesContainer }, React.createElement("img", { ...imageProps, src: src, "data-hook": DATA_HOOKS.carouselImage, className: classes.image, onLoad: () => onImageLoad(src), style: { objectPosition: imagesPosition, objectFit: imagesFit, ...style, } })), isLoading(src) && (React.createElement("div", { className: classes.loader }, React.createElement(Loader, { dataHook: DATA_HOOKS.loader, size: "small" })))))); }); }, [imagesPosition, imagesFit, isLoading, onImageLoad, slidesInView]); const scrollPrev = useCallback(() => { const currentIndex = currentIndexRef.current; if (!shouldLoop && currentIndex <= 0) return; const target = currentIndex === 0 ? slideCount - 1 : currentIndex - 1; programmaticTargetRef.current = target; performScroll(target, 'prev'); }, [performScroll, shouldLoop, slideCount]); const scrollNext = useCallback(() => { const currentIndex = currentIndexRef.current; if (!shouldLoop && currentIndex >= slideCount - 1) return; const target = currentIndex === slideCount - 1 ? 0 : currentIndex + 1; programmaticTargetRef.current = target; performScroll(target, 'next'); }, [performScroll, shouldLoop, slideCount]); // Keyboard navigation: ArrowLeft/ArrowRight when focus is inside carousel. const handleKeyDown = useCallback((e) => { if (e.key === 'ArrowLeft') { e.preventDefault(); scrollPrev(); } else if (e.key === 'ArrowRight') { e.preventDefault(); scrollNext(); } }, [scrollPrev, scrollNext]); // When focus moves into a slide that is not in view, scroll it into view. const handleFocusCapture = useCallback((e) => { if (!emblaApi) return; const focusedEl = e.target; const slideNodes = emblaApi.slideNodes(); const slideIndex = slideNodes.findIndex(node => node.contains(focusedEl)); if (slideIndex !== -1 && !slidesInView.includes(slideIndex)) { scrollTo(slideIndex); } }, [emblaApi, slidesInView, scrollTo]); const renderControlArrows = () => { if (controlsPosition === 'none') return null; return (React.createElement(React.Fragment, null, React.createElement(SliderArrow, { dataHook: DATA_HOOKS.prevButton, buttonSkin: buttonSkin, arrowSize: controlsSize, icon: leftIcon(controlsSize), controlsStartEnd: controlsStartEnd, onClick: scrollPrev, disabled: !shouldLoop && prevBtnDisabled, ariaLabel: "Previous slide", gradientClassName: !!gradient && controlsPosition === 'overlay' ? classes.arrowPrevBackground : undefined }), React.createElement(SliderArrow, { dataHook: DATA_HOOKS.nextButton, buttonSkin: buttonSkin, arrowSize: controlsSize, icon: rightIcon(controlsSize), controlsStartEnd: controlsStartEnd, onClick: scrollNext, disabled: !shouldLoop && nextBtnDisabled, ariaLabel: "Next slide", gradientClassName: !!gradient && controlsPosition === 'overlay' ? classes.arrowNextBackground : undefined }))); }; const renderDotsNavigation = () => { if (!dots) return null; const dotElements = Array.from({ length: slideCount }, (_, i) => (React.createElement("div", { key: i, className: st(classes.pageNavigation, { active: i === selectedIndex }), "data-hook": DATA_HOOKS.pageNavigation(i), "data-active": i === selectedIndex, onClick: () => scrollTo(i) }, i))); return (React.createElement(Pagination, { originalClassName: st(classes.pagination, { controlsPosition }), pages: dotElements })); }; const hasImages = !children && images.length > 0; const slideSpacingStyle = slideSpacing !== undefined ? { '--slide-spacing': `${slideSpacing}px` } : {}; return (React.createElement("div", { "data-hook": dataHook, role: "region", "aria-roledescription": "carousel", "aria-label": "Carousel", onKeyDown: handleKeyDown, onFocusCapture: handleFocusCapture, className: `${st(classes.root, { controlsPosition, controlsSize, showControlsShadow, gradient, customGradient: !!gradientColor, dots, variableWidth, }, className)} embla`, style: { [vars['carousel-gradient-color']]: gradientColor, ...slideSpacingStyle, } }, React.createElement("div", { className: classes.emblaWrapper }, renderControlArrows(), React.createElement("div", { className: "embla__viewport", ref: emblaRef }, React.createElement("div", { className: "embla__container" }, children ? React.Children.map(children, (child, index) => { const isInView = slidesInView.includes(index); return (React.createElement("div", { key: index, className: "embla__slide", "data-index": index, role: "group", "aria-roledescription": "slide", "aria-label": `Slide ${index + 1} of ${slideCount}`, "aria-hidden": !isInView, tabIndex: isInView ? undefined : -1 }, child)); }) : hasImages && renderImages(images)))), renderDotsNavigation())); }); Carousel.displayName = 'Carousel'; export default Carousel; //# sourceMappingURL=Carousel.js.map