import * as React from 'react'; import { CSSProperties, useContext, useRef, useEffect, MouseEvent } from 'react'; import Arrow from '../../../Icons/Arrows/Arrow'; import { StoreContext } from '../../../../services/store'; import { IMedia } from '../../../../types'; import { uniformCarouselTransitionDurationSec, ugcZIndex, } from '../../../consts'; import { useAccessibilityMessage } from '../../../../hooks/useAccessibilityMessage'; import { useUniformCenteredConfig, useCarouselLayout, useInfiniteNavigation, } from '../shared/hooks'; import { useUniformTranslateX } from '../UniformCarousel/hooks'; import { getCarouselMovementMessage, getSrOnlyStyle, normalizeIndex, } from '../shared/accessibility'; import { useBuyBoxScrollState } from './hooks'; interface Props { items: T[]; nbElems: number; style?: CSSProperties; onPostChange: (post: IMedia | null) => void; } /** * This is a compact desktop-only carousel designed to fit inside a product-page "Buy Box" column. */ const BuyBoxCarousel = (props: Props) => { const context = useContext(StoreContext); const slideRef = useRef(null); const containerRef = useRef(null); const outerRef = useRef(null); const { hasScrolled, markScrolled } = useBuyBoxScrollState(); const itemCount = props.items.length; const { accessibilityMessage, announce } = useAccessibilityMessage(); const visibleContentSize = Math.max(props.nbElems, 1); const contentSize = context.data.settings.content_size; const { contentSizeNotReached, cloneCount } = useUniformCenteredConfig( itemCount, visibleContentSize, context.setStoreState, ); const { compactStyle, carouselDimensions, arrowSize } = useCarouselLayout({ context: { widgetWidth: context.widgetWidth, contentSize, isMobile: context.isMobile, // Tell the dimension hook there are no side arrows so it does NOT subtract // arrow space from the available content width. hideArrows: true, }, config: { contentSize: visibleContentSize, itemCount, partialItemWidth: contentSizeNotReached ? 0 : 0.5, alwaysPartial: true, }, refs: { slideRef }, }); const { activeIndex, handleSlide, skipTransition } = useInfiniteNavigation({ config: { itemCount, contentSize: visibleContentSize, cloneCount, contentSizeNotReached }, refs: { slideRef, containerRef }, state: { itemWidth: carouselDimensions.itemWidth, currentPosition: context.currentPosition }, actions: { setStoreState: context.setStoreState }, transitionDurationSec: uniformCarouselTransitionDurationSec, }); const { getTranslateX } = useUniformTranslateX({ config: { cloneCount }, state: { itemWidth: carouselDimensions.itemWidth, activeIndex }, }); const onArrowClick = ( event: MouseEvent | undefined, direction: 'left' | 'right', ) => { // Reveal the left arrow permanently after the first scroll. markScrolled(); const nextIndex = activeIndex + (direction === 'left' ? -1 : 1); const normalizedIndex = normalizeIndex(nextIndex, itemCount); announce(getCarouselMovementMessage(normalizedIndex, itemCount)); handleSlide(direction); if (event) { context.triggerEvent( 'carouselArrowClicked', { position: activeIndex, originalEvent: event.nativeEvent, direction, }, context.data.id, ); } }; const renderSlides = () => { const { itemWidth } = carouselDimensions; if (cloneCount === 0) { return props.items.map((item, i) => renderSlide(item, i, itemWidth)); } const cloneStart = props.items .slice(props.items.length - cloneCount) .map((item, i) => renderSlide(item, i - cloneCount, itemWidth)); const realSlides = props.items.map((item, i) => renderSlide(item, i, itemWidth)); const cloneEnd = props.items .slice(0, cloneCount) .map((item, i) => renderSlide(item, props.items.length + i, itemWidth)); return [...cloneStart, ...realSlides, ...cloneEnd]; }; const renderSlide = (item: JSX.Element, index: number, width: number) => { const isClone = index < 0 || index >= itemCount; const cloneAccessibilityProps = isClone ? { 'aria-hidden': true, 'data-carousel-clone': 'true' } : {}; return (
{item}
); }; // Arrows are only rendered when there is more than one item, the global // hideArrows flag is not set, and the product list is not open. const showArrows = itemCount > 1 && !context.config.hideArrows && !contentSizeNotReached && !context.post; // Left arrow is hidden until the carousel has been scrolled at least once. // After the first scroll the loop guarantees previous content always exists. const showLeftArrow = showArrows && hasScrolled; const showRightArrow = showArrows; const leftPosition = getTranslateX(); return (
{/* Screen-reader-only live region for navigation announcements */}
{accessibilityMessage}
{/* Scrollable track — takes the full container width */}
{renderSlides()}
{/* * Overlaid left arrow. * Hidden on init; fades in permanently after the first navigation action. * * Visibility is controlled exclusively by the wrapper (opacity + inert), * so Arrow always renders a
); }; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const carouselStyle: CSSProperties = { width: '100%', position: 'relative', minHeight: 'fit-content', }; const desktopSlideStyle: CSSProperties = { width: '100%', height: 'auto', overflow: 'hidden', position: 'relative', display: 'flex', alignItems: 'center', }; const desktopSlideContainerStyle: CSSProperties = { position: 'relative', height: 'auto', display: 'flex', alignItems: 'center', willChange: 'transform', transition: `transform ${uniformCarouselTransitionDurationSec}s ease`, }; const slideItemStyle: CSSProperties = { display: 'inline-block', height: 'auto', flexShrink: 0, boxSizing: 'border-box', isolation: 'auto', }; /** * Positions an arrow overlay on the left or right edge of the carousel, * stretching the full height so the Arrow button can use height: 100%. */ const getOverlayArrowWrapperStyle = ( direction: 'left' | 'right', visible: boolean, ): CSSProperties => ({ position: 'absolute', top: 0, bottom: 0, [direction]: 0, display: 'flex', alignItems: 'center', zIndex: ugcZIndex + 1, backgroundColor: 'white', padding: '0 4px', opacity: visible ? 1 : 0, transition: 'opacity 0.2s ease', pointerEvents: visible ? 'auto' : 'none', }); export default BuyBoxCarousel;