UNPKG

@wix/design-system

Version:

@wix/design-system

637 lines (627 loc) 25.6 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); exports.__esModule = true; exports.default = void 0; var _extends2 = _interopRequireDefault(require("@babel/runtime/helpers/extends")); var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _objectWithoutProperties2 = _interopRequireDefault(require("@babel/runtime/helpers/objectWithoutProperties")); var _react = _interopRequireWildcard(require("react")); var _emblaCarouselReact = _interopRequireDefault(require("embla-carousel-react")); var _emblaCarouselAutoplay = _interopRequireDefault(require("embla-carousel-autoplay")); var _wixUiIconsCommon = require("@wix/wix-ui-icons-common"); var _matchMediaRegister = require("./utils/match-media-register"); var _CarouselSt = require("./Carousel.st.css.js"); var _Pagination = _interopRequireDefault(require("./Pagination")); var _SliderArrow = _interopRequireDefault(require("./SliderArrow")); var _Loader = _interopRequireDefault(require("../Loader")); var _Proportion = _interopRequireDefault(require("../Proportion")); var _Proportion2 = require("../Proportion/Proportion.constants"); var _Carousel = require("./Carousel.constants"); var _excluded = ["src", "style"]; var _jsxFileName = "/home/builduser/work/57e038ea7326c1ec/packages/wix-design-system/dist/cjs/Carousel/Carousel.tsx"; // This is here and not in the test setup because we don't want consumers to need to run it as well function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (var _t in e) "default" !== _t && {}.hasOwnProperty.call(e, _t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, _t)) && (i.get || i.set) ? o(f, _t, i) : f[_t] = e[_t]); return f; })(e, t); } function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } if (_matchMediaRegister.isTestEnv && typeof window !== 'undefined' && !window.matchMedia) { (0, _matchMediaRegister.registerWindowMatchMedia)(); } if (_matchMediaRegister.isTestEnv) { (0, _matchMediaRegister.registerIntersectionObserver)(); (0, _matchMediaRegister.registerResizeObserver)(); } var AUTOPLAY_SPEED = 3000; var TRANSITION_SPEED = 600; function inferDirection(oldIndex, newIndex, count, isLoop) { if (!isLoop) { return newIndex > oldIndex ? 'next' : 'prev'; } var forward = (newIndex - oldIndex + count) % count; var 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; var result = index % count; return result < 0 ? result + count : result; } function leftIcon(controlSize) { switch (controlSize) { case 'tiny': return /*#__PURE__*/_react.default.createElement(_wixUiIconsCommon.ChevronLeftSmall, { __self: this, __source: { fileName: _jsxFileName, lineNumber: 93, columnNumber: 14 } }); case 'small': return /*#__PURE__*/_react.default.createElement(_wixUiIconsCommon.ChevronLeftLargeSmall, { __self: this, __source: { fileName: _jsxFileName, lineNumber: 95, columnNumber: 14 } }); default: return /*#__PURE__*/_react.default.createElement(_wixUiIconsCommon.ChevronLeftLarge, { __self: this, __source: { fileName: _jsxFileName, lineNumber: 97, columnNumber: 14 } }); } } function rightIcon(controlSize) { switch (controlSize) { case 'tiny': return /*#__PURE__*/_react.default.createElement(_wixUiIconsCommon.ChevronRightSmall, { __self: this, __source: { fileName: _jsxFileName, lineNumber: 104, columnNumber: 14 } }); case 'small': return /*#__PURE__*/_react.default.createElement(_wixUiIconsCommon.ChevronRightLargeSmall, { __self: this, __source: { fileName: _jsxFileName, lineNumber: 106, columnNumber: 14 } }); default: return /*#__PURE__*/_react.default.createElement(_wixUiIconsCommon.ChevronRightLarge, { __self: this, __source: { fileName: _jsxFileName, lineNumber: 108, columnNumber: 14 } }); } } var Carousel = /*#__PURE__*/(0, _react.forwardRef)((props, ref) => { var { 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. var align = alignProp !== null && alignProp !== void 0 ? alignProp : 'start'; var startEndOffset = alignProp !== undefined ? undefined : startEndOffsetProp; var [loadedImages, setLoadedImages] = (0, _react.useState)([]); var slideCount = children ? _react.default.Children.count(children) : images.length; var normalizedInitialIndex = normalizeIndex(initialSlideIndex, slideCount); var [selectedIndex, setSelectedIndex] = (0, _react.useState)(normalizedInitialIndex); var currentIndexRef = (0, _react.useRef)(normalizedInitialIndex); var programmaticTargetRef = (0, _react.useRef)(null); var shouldLoop = infinite && slideCount > 1; var peekOffsetTargetRef = (0, _react.useRef)(null); var peekOffsetDirectionRef = (0, _react.useRef)(null); var [prevBtnDisabled, setPrevBtnDisabled] = (0, _react.useState)(!shouldLoop && normalizedInitialIndex === 0); var [nextBtnDisabled, setNextBtnDisabled] = (0, _react.useState)(!shouldLoop && normalizedInitialIndex === slideCount - 1); var [slidesInView, setSlidesInView] = (0, _react.useState)( // In test env, IntersectionObserver is a no-op so embla.slidesInView() // stays empty. Seed with the initial slide so aria-hidden is correct. _matchMediaRegister.isTestEnv ? [normalizedInitialIndex] : []); var emblaDuration = animationDuration ? Math.min(Math.max(animationDuration / 25, 20), 60) : TRANSITION_SPEED / 25; var slidesToScroll = (0, _react.useMemo)(() => { switch (slidingType) { case 'align-to-start': return undefined; case 'reveal-one': return 1; case 'reveal-chunk': return 'auto'; default: return undefined; } }, [slidingType]); var containScroll = (0, _react.useMemo)(() => { if (variableWidth && shouldLoop) { return undefined; } return 'trimSnaps'; }, [shouldLoop, variableWidth]); var emblaOptions = (0, _react.useMemo)(() => ({ loop: shouldLoop, startIndex: normalizedInitialIndex, duration: emblaDuration, align, slidesToScroll, containScroll, skipSnaps: false }), [align, containScroll, emblaDuration, normalizedInitialIndex, shouldLoop, slidesToScroll]); var [emblaRef, emblaApi] = (0, _emblaCarouselReact.default)(emblaOptions, autoplay ? [(0, _emblaCarouselAutoplay.default)({ 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). var calculatePeekPosition = (0, _react.useCallback)((baseX, direction) => { if (!startEndOffset || startEndOffset <= 0) { return baseX; } var alignDir = getAlignDirection(direction); var gap = slideSpacing !== null && slideSpacing !== void 0 ? slideSpacing : 0; return alignDir === 'right' ? baseX - (startEndOffset + gap) : baseX + startEndOffset; }, [startEndOffset, slideSpacing]); var transitionSlide = (0, _react.useCallback)((fromIndex, toIndex) => { if (fromIndex === toIndex) return false; beforeChange == null || beforeChange(fromIndex, toIndex); setSelectedIndex(toIndex); currentIndexRef.current = toIndex; return true; }, [beforeChange]); var onSelect = (0, _react.useCallback)(api => { if (!api) return; var emblaIndex = api.selectedScrollSnap(); var pendingTarget = programmaticTargetRef.current; var newIndex = pendingTarget !== null ? pendingTarget : emblaIndex; var oldIndex = currentIndexRef.current; programmaticTargetRef.current = null; if (!shouldLoop) { var prevDisabled = !api.canScrollPrev(); var 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) { var engine = api.internalEngine(); var currentSnap = engine.scrollSnaps[emblaIndex]; if (!nextDisabled) { var nextPeek = calculatePeekPosition(currentSnap, 'next'); if (nextPeek < engine.limit.min) { nextDisabled = true; } } if (!prevDisabled) { var 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 && !_matchMediaRegister.isTestEnv) { var dir = inferDirection(oldIndex, newIndex, slideCount, shouldLoop); var _engine = api.internalEngine(); var 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]); var onSettle = (0, _react.useCallback)(() => { afterChange == null || afterChange(currentIndexRef.current); }, [afterChange]); (0, _react.useEffect)(() => { if (!emblaApi) return; emblaApi.on('select', onSelect); return () => { emblaApi.off('select', onSelect); }; }, [emblaApi, onSelect]); (0, _react.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. (0, _react.useEffect)(() => { if (!emblaApi || !startEndOffset || startEndOffset <= 0 || _matchMediaRegister.isTestEnv) return; var onPointerUp = () => { var 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. (0, _react.useEffect)(() => { if (!emblaApi || _matchMediaRegister.isTestEnv) return; var updateSlidesInView = () => { setSlidesInView(emblaApi.slidesInView()); }; emblaApi.on('slidesInView', updateSlidesInView); // Initial sync updateSlidesInView(); return () => { emblaApi.off('slidesInView', updateSlidesInView); }; }, [emblaApi]); var syncStateForTests = (0, _react.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 == null || 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. var performScroll = (0, _react.useCallback)((targetIndex, direction) => { var hasOffset = startEndOffset !== undefined && startEndOffset > 0 && !!direction; if (hasOffset && !_matchMediaRegister.isTestEnv && emblaApi) { emblaApi.scrollTo(targetIndex, false); var engine = emblaApi.internalEngine(); var adjustedTarget = calculatePeekPosition(engine.target.get(), direction); if (!shouldLoop) { adjustedTarget = clampToEngineLimits(engine, adjustedTarget); } engine.target.set(adjustedTarget); var 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 == null || emblaApi.scrollTo(targetIndex, _matchMediaRegister.isTestEnv); peekOffsetTargetRef.current = null; peekOffsetDirectionRef.current = null; } if (_matchMediaRegister.isTestEnv) { syncStateForTests(targetIndex); } }, [emblaApi, startEndOffset, shouldLoop, calculatePeekPosition, syncStateForTests]); var scrollTo = (0, _react.useCallback)(index => { programmaticTargetRef.current = index; var currentIndex = currentIndexRef.current; var direction = index > currentIndex ? 'next' : index < currentIndex ? 'prev' : undefined; performScroll(index, direction); }, [performScroll]); (0, _react.useImperativeHandle)(ref, () => ({ slideTo: index => { scrollTo(index); }, leftIconByControlSize: leftIcon, rightIconByControlSize: rightIcon }), [scrollTo]); var onImageLoad = (0, _react.useCallback)(src => { setLoadedImages(prev => [...prev, src]); }, []); var isLoading = (0, _react.useCallback)(src => !loadedImages.includes(src), [loadedImages]); var renderImages = (0, _react.useCallback)(imagesList => { return imagesList.map((image, index) => { var { src, style } = image, imageProps = (0, _objectWithoutProperties2.default)(image, _excluded); var isInView = slidesInView.includes(index); return /*#__PURE__*/_react.default.createElement("div", { key: "".concat(index).concat(src), className: "embla__slide", "data-index": index, role: "group", "aria-roledescription": "slide", "aria-label": "Slide ".concat(index + 1, " of ").concat(imagesList.length), "aria-hidden": !isInView, tabIndex: isInView ? undefined : -1, __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 521, columnNumber: 11 } }, /*#__PURE__*/_react.default.createElement(_Proportion.default, { aspectRatio: _Proportion2.PREDEFINED_RATIOS.landscape, __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 531, columnNumber: 13 } }, /*#__PURE__*/_react.default.createElement("div", { className: (0, _CarouselSt.st)(_CarouselSt.classes.imageContainer, { isLoading: isLoading(src) }), "data-hook": _Carousel.DATA_HOOKS.imagesContainer, __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 532, columnNumber: 15 } }, /*#__PURE__*/_react.default.createElement("img", (0, _extends2.default)({}, imageProps, { src: src, "data-hook": _Carousel.DATA_HOOKS.carouselImage, className: _CarouselSt.classes.image, onLoad: () => onImageLoad(src), style: _objectSpread({ objectPosition: imagesPosition, objectFit: imagesFit }, style), __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 538, columnNumber: 17 } }))), isLoading(src) && /*#__PURE__*/_react.default.createElement("div", { className: _CarouselSt.classes.loader, __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 554, columnNumber: 17 } }, /*#__PURE__*/_react.default.createElement(_Loader.default, { dataHook: _Carousel.DATA_HOOKS.loader, size: "small", __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 555, columnNumber: 19 } })))); }); }, [imagesPosition, imagesFit, isLoading, onImageLoad, slidesInView]); var scrollPrev = (0, _react.useCallback)(() => { var currentIndex = currentIndexRef.current; if (!shouldLoop && currentIndex <= 0) return; var target = currentIndex === 0 ? slideCount - 1 : currentIndex - 1; programmaticTargetRef.current = target; performScroll(target, 'prev'); }, [performScroll, shouldLoop, slideCount]); var scrollNext = (0, _react.useCallback)(() => { var currentIndex = currentIndexRef.current; if (!shouldLoop && currentIndex >= slideCount - 1) return; var 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. var handleKeyDown = (0, _react.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. var handleFocusCapture = (0, _react.useCallback)(e => { if (!emblaApi) return; var focusedEl = e.target; var slideNodes = emblaApi.slideNodes(); var slideIndex = slideNodes.findIndex(node => node.contains(focusedEl)); if (slideIndex !== -1 && !slidesInView.includes(slideIndex)) { scrollTo(slideIndex); } }, [emblaApi, slidesInView, scrollTo]); var renderControlArrows = () => { if (controlsPosition === 'none') return null; return /*#__PURE__*/_react.default.createElement(_react.default.Fragment, null, /*#__PURE__*/_react.default.createElement(_SliderArrow.default, { dataHook: _Carousel.DATA_HOOKS.prevButton, buttonSkin: buttonSkin, arrowSize: controlsSize, icon: leftIcon(controlsSize), controlsStartEnd: controlsStartEnd, onClick: scrollPrev, disabled: !shouldLoop && prevBtnDisabled, ariaLabel: "Previous slide", gradientClassName: !!gradient && controlsPosition === 'overlay' ? _CarouselSt.classes.arrowPrevBackground : undefined, __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 619, columnNumber: 9 } }), /*#__PURE__*/_react.default.createElement(_SliderArrow.default, { dataHook: _Carousel.DATA_HOOKS.nextButton, buttonSkin: buttonSkin, arrowSize: controlsSize, icon: rightIcon(controlsSize), controlsStartEnd: controlsStartEnd, onClick: scrollNext, disabled: !shouldLoop && nextBtnDisabled, ariaLabel: "Next slide", gradientClassName: !!gradient && controlsPosition === 'overlay' ? _CarouselSt.classes.arrowNextBackground : undefined, __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 635, columnNumber: 9 } })); }; var renderDotsNavigation = () => { if (!dots) return null; var dotElements = Array.from({ length: slideCount }, (_, i) => /*#__PURE__*/_react.default.createElement("div", { key: i, className: (0, _CarouselSt.st)(_CarouselSt.classes.pageNavigation, { active: i === selectedIndex }), "data-hook": _Carousel.DATA_HOOKS.pageNavigation(i), "data-active": i === selectedIndex, onClick: () => scrollTo(i), __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 658, columnNumber: 7 } }, i)); return /*#__PURE__*/_react.default.createElement(_Pagination.default, { originalClassName: (0, _CarouselSt.st)(_CarouselSt.classes.pagination, { controlsPosition }), pages: dotElements, __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 670, columnNumber: 7 } }); }; var hasImages = !children && images.length > 0; var slideSpacingStyle = slideSpacing !== undefined ? { '--slide-spacing': "".concat(slideSpacing, "px") } : {}; return /*#__PURE__*/_react.default.createElement("div", { "data-hook": dataHook, role: "region", "aria-roledescription": "carousel", "aria-label": "Carousel", onKeyDown: handleKeyDown, onFocusCapture: handleFocusCapture, className: "".concat((0, _CarouselSt.st)(_CarouselSt.classes.root, { controlsPosition, controlsSize, showControlsShadow, gradient, customGradient: !!gradientColor, dots, variableWidth }, className), " embla"), style: _objectSpread({ [_CarouselSt.vars['carousel-gradient-color']]: gradientColor }, slideSpacingStyle), __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 684, columnNumber: 5 } }, /*#__PURE__*/_react.default.createElement("div", { className: _CarouselSt.classes.emblaWrapper, __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 711, columnNumber: 7 } }, renderControlArrows(), /*#__PURE__*/_react.default.createElement("div", { className: "embla__viewport", ref: emblaRef, __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 714, columnNumber: 9 } }, /*#__PURE__*/_react.default.createElement("div", { className: "embla__container", __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 715, columnNumber: 11 } }, children ? _react.default.Children.map(children, (child, index) => { var isInView = slidesInView.includes(index); return /*#__PURE__*/_react.default.createElement("div", { key: index, className: "embla__slide", "data-index": index, role: "group", "aria-roledescription": "slide", "aria-label": "Slide ".concat(index + 1, " of ").concat(slideCount), "aria-hidden": !isInView, tabIndex: isInView ? undefined : -1, __self: void 0, __source: { fileName: _jsxFileName, lineNumber: 720, columnNumber: 21 } }, child); }) : hasImages && renderImages(images)))), renderDotsNavigation()); }); Carousel.displayName = 'Carousel'; var _default = exports.default = Carousel; //# sourceMappingURL=Carousel.js.map