import * as React from 'react'; import { CSSProperties, useContext, useRef, useLayoutEffect, isValidElement, cloneElement, ReactElement } from 'react'; import { StoreContext } from '../../../../services/store'; import { IMedia } from '../../../../types'; import { useMobileCarouselDimensions, useMobileScrollSync } from './hooks'; import { getCarouselCloneAccessibilityProps } from '../shared/accessibility'; interface Props { items: T[]; nbElems: number; style?: CSSProperties; onPostChange: (post: IMedia | null) => void; } const MobileCarousel = (props: Props) => { const context = useContext(StoreContext); const scrollContainerRef = useRef(null); const itemCount = props.items.length; const visibleContentSize = Math.max(props.nbElems, 1); // Use clones only when there is more than one item. // Clone enough items to fill the visible viewport and support seamless wrapping. const needsCloning = itemCount > 1; const cloneCount = needsCloning ? Math.max(visibleContentSize, itemCount) : 0; const { itemWidthPercentage } = useMobileCarouselDimensions(visibleContentSize); const { handleScroll } = useMobileScrollSync({ scrollContainerRef, itemCount, cloneCount, visibleContentSize, setStoreState: context.setStoreState, triggerEvent: context.triggerEvent, widgetId: context.data.id, }); // Render slides and optional clone buffer for infinite scrolling. const renderSlides = () => { if (!needsCloning) { return props.items.map((item, i) => renderSlide(item, i, i)); } // Clone items at the start and end for seamless looping. // No spacers are required because scroll-padding keeps the shoulder spacing. const cloneStart = props.items.slice(props.items.length - cloneCount).map((item, i) => { const idx = props.items.length - cloneCount + i; return renderSlide(item, idx, i, 'clone-start'); }); const realSlides = props.items.map((item, i) => renderSlide(item, i, cloneCount + i, 'real')); const cloneEnd = props.items .slice(0, cloneCount) .map((item, i) => renderSlide(item, i, cloneCount + props.items.length + i, 'clone-end')); return [...cloneStart, ...realSlides, ...cloneEnd]; }; const renderSlide = ( item: JSX.Element, originalIndex: number, renderIndex: number, type?: string, ) => { const isClone = type === 'clone-start' || type === 'clone-end'; const cloneAccessibilityProps = getCarouselCloneAccessibilityProps(isClone); const slideContent = isValidElement(item) ? cloneElement(item as ReactElement>, { isCarouselClone: isClone, }) : item; return (
{slideContent}
); }; // Initialize the starting scroll offset so the first real slide appears with the expected shoulder spacing. useLayoutEffect(() => { if (!scrollContainerRef.current || !needsCloning) return; const container = scrollContainerRef.current; const { scrollWidth, clientWidth } = container; const totalItems = itemCount + 2 * cloneCount; const itemWidth = scrollWidth / totalItems; // Apply the start position immediately, without smooth animation. container.style.scrollBehavior = 'auto'; if (visibleContentSize === 1) { // Single item: center the first real item and let the remaining width form shoulders. const scrollPos = itemWidth * cloneCount; container.scrollLeft = scrollPos; } else { // Multi-item: position the first real item so 5% shoulder is visible on the left. const firstRealItemPos = itemWidth * cloneCount; const shoulderOffset = clientWidth * 0.05; // 5% of viewport const scrollPos = firstRealItemPos - shoulderOffset; container.scrollLeft = scrollPos; } // Re-enable smooth scrolling for subsequent user interactions requestAnimationFrame(() => { if (container) { container.style.scrollBehavior = 'smooth'; } }); // Inject CSS to hide scrollbar in WebKit browsers const style = document.createElement('style'); style.textContent = ` .${context.config.classPrefix}-mobile-carousel div::-webkit-scrollbar { display: none; } `; document.head.appendChild(style); // Cleanup: remove injected style on unmount const cleanup = () => { if (style.parentNode) { document.head.removeChild(style); } }; // eslint-disable-next-line consistent-return return cleanup; }, []); // Run once on mount return (
1 ? '5%' : '0', }} > {renderSlides()}
); }; const carouselStyle: CSSProperties = { width: '100%', display: 'flex', position: 'relative', minHeight: 'fit-content', overflow: 'hidden', }; const scrollContainerStyle: CSSProperties = { display: 'flex', overflowX: 'scroll', overflowY: 'hidden', scrollSnapType: 'x mandatory', scrollBehavior: 'auto', // overridden to 'smooth' after initial position is set WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none', // Firefox msOverflowStyle: 'none', // IE/Edge width: '100%', paddingTop: '20px', paddingBottom: '20px', }; const slideItemStyle: CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'center', height: 'auto', boxSizing: 'border-box', position: 'relative', }; export default MobileCarousel;