import { ChevronLeft, ChevronRight } from '@transferwise/icons'; import { clsx } from 'clsx'; import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react'; import IconButton from '../iconButton'; import Title from '../title'; import type { PromoCardLinkProps } from '../promoCard/PromoCard'; import PromoCard from '../promoCard/PromoCard'; export type CarouselCardCommon = { id: string; href?: string; hrefTarget?: HTMLAnchorElement['target']; onClick?: () => void; className?: string; styles?: CSSProperties; }; export type CarouselDefaultCard = CarouselCardCommon & { type: 'anchor' | 'button'; content: ReactNode; }; export type CarouselPromoCard = CarouselCardCommon & { type: 'promo'; } & Omit; export type CarouselCard = CarouselDefaultCard | CarouselPromoCard; export interface CarouselProps { header: string | ReactNode; className?: string; cards: CarouselCard[]; onClick?: (cardId: string) => void; } type CardsReference = { type: 'promo' | 'default'; cardElement: HTMLDivElement | HTMLAnchorElement; anchorElement?: HTMLAnchorElement; }; const LEFT_SCROLL_OFFSET = 8; const Carousel: React.FC = ({ header, className, cards, onClick }) => { const [scrollPosition, setScrollPosition] = useState(0); const [previousScrollPosition, setPreviousScrollPosition] = useState(0); const [scrollIsAtEnd, setScrollIsAtEnd] = useState(false); const [visibleCardOnMobileView, setVisibleCardOnMobileView] = useState(''); const carouselElementRef = useRef(null); const carouselCardsRef = useRef([]); const isLeftActionButtonEnabled = scrollPosition > LEFT_SCROLL_OFFSET; const areActionButtonsEnabled = isLeftActionButtonEnabled || !scrollIsAtEnd; const [focusedCard, setFocusedCard] = useState(cards?.[0]?.id); const updateScrollButtonsState = () => { if (carouselElementRef.current) { const { scrollWidth, offsetWidth } = carouselElementRef.current; const scrollAtEnd = scrollWidth - offsetWidth <= scrollPosition + LEFT_SCROLL_OFFSET; setScrollIsAtEnd(scrollAtEnd); } const scrollDirecton = scrollPosition > previousScrollPosition ? 'right' : 'left'; const cardsInFullViewIds: string[] = []; carouselCardsRef.current.forEach((card) => { if (isVisible(carouselElementRef.current as HTMLElement, card.cardElement as HTMLElement)) { // eslint-disable-next-line functional/immutable-data cardsInFullViewIds.push(card.cardElement.getAttribute('id') ?? ''); } }); if (cardsInFullViewIds.length >= 1) { const visibleCardIndex = scrollDirecton === 'right' ? cardsInFullViewIds.length - 1 : 0; const visibleCardId = cardsInFullViewIds[visibleCardIndex]; setVisibleCardOnMobileView(visibleCardId); setFocusedCard(visibleCardId); } setPreviousScrollPosition(scrollPosition); }; const scrollCarousel = (direction: 'left' | 'right' = 'right') => { if (carouselElementRef.current) { const { scrollWidth } = carouselElementRef.current; const cardWidth = scrollWidth / carouselCardsRef.current.length; const res = Math.floor(cardWidth - cardWidth * 0.05); carouselElementRef.current.scrollBy({ left: direction === 'right' ? res : -res, behavior: 'smooth', }); } }; const handleOnKeyDown = ( event: React.KeyboardEvent, index: number, ) => { if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { const nextIndex = event.key === 'ArrowRight' ? index + 1 : index - 1; const nextCard = cards[nextIndex]; if (nextCard) { const ref = carouselCardsRef.current[nextIndex]; if (ref.type === 'promo') { ref.anchorElement?.focus(); } else { ref.cardElement?.focus(); } scrollCardIntoView(carouselCardsRef.current[nextIndex].cardElement, nextCard); event.preventDefault(); } } if (event.key === 'Enter' || event.key === ' ') { event.currentTarget.click(); } }; const scrollCardIntoView = (element: HTMLElement, card: CarouselCard) => { element.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center', }); setFocusedCard(card.id); }; useEffect(() => { updateScrollButtonsState(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [scrollPosition]); useEffect(() => { window.addEventListener('resize', updateScrollButtonsState); return () => { window.removeEventListener('resize', updateScrollButtonsState); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const addElementToCardsRefArray = (index: number, ref: Partial) => { if (ref) { // eslint-disable-next-line functional/immutable-data carouselCardsRef.current[index] = { type: ref.type ?? carouselCardsRef.current?.[index]?.type, cardElement: ref.cardElement ?? carouselCardsRef.current?.[index]?.cardElement, anchorElement: ref.anchorElement ?? carouselCardsRef.current?.[index]?.anchorElement, }; } }; return (
{typeof header === 'string' ? ( {header} ) : ( header )} {areActionButtonsEnabled ? (
) : null}
{ const target = event.target as HTMLElement; setScrollPosition(target.scrollLeft); }} > {cards?.map((card, index) => { const sharedProps = { id: card.id, className: clsx('carousel__card', { 'carousel__card--focused': card.id === focusedCard, }), onClick: () => { card.onClick?.(); onClick?.(card.id); }, onFocus: (event: React.FocusEvent) => { scrollCardIntoView(event.currentTarget, card); }, }; const cardContent = card.type !== 'promo' ? (
{card.content}
) : null; if (card.type === 'button') { return (
{ if (el) { // eslint-disable-next-line functional/immutable-data carouselCardsRef.current[index] = { type: 'default', cardElement: el, }; } }} role="button" tabIndex={0} onKeyDown={(event) => handleOnKeyDown(event, index)} > {cardContent}
); } if (card.type === 'promo') { return (
{ if (el) { addElementToCardsRefArray(index, { type: 'promo', cardElement: el, }); } }} anchorRef={(el: HTMLAnchorElement) => { if (el) { addElementToCardsRefArray(index, { type: 'promo', anchorElement: el, }); } }} anchorId={`${card.id}-anchor`} onKeyDown={(event) => handleOnKeyDown(event, index)} />
); } return ( ); })}
{cards?.map((card, index) => (
); }; const isVisible = (container: HTMLElement, el: HTMLElement) => { const cWidth = container.offsetWidth; const cScrollOffset = container.scrollLeft; const elemLeft = el.offsetLeft - container.offsetLeft; const elemRight = elemLeft + el.offsetWidth; return elemLeft >= cScrollOffset && elemRight <= cScrollOffset + cWidth; }; export default Carousel;