@wix/design-system
Version:
@wix/design-system
440 lines • 20.8 kB
JavaScript
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