mirror of
https://github.com/XRPLF/xrpl-dev-portal.git
synced 2026-06-07 18:56:38 +00:00
- Introduced a constant for the BEM class name to enhance readability. - Updated buttonVariant prop type to derive from ButtonProps for consistency. - Replaced hardcoded class name with the new constant in the component's render method.
257 lines
7.8 KiB
TypeScript
257 lines
7.8 KiB
TypeScript
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
|
import clsx from 'clsx';
|
|
import { CardOffgrid, CardOffgridProps } from '../../components/CardOffgrid';
|
|
import type { ButtonProps } from '../../components/Button';
|
|
|
|
/**
|
|
* Configuration for a single card in the CarouselCardList pattern
|
|
* Extends CardOffgridProps but removes variant (controlled by carousel)
|
|
*/
|
|
export type CarouselCardConfig = Omit<CardOffgridProps, 'variant'>;
|
|
|
|
/** BEM class name for card elements */
|
|
const CARD_CLASS_NAME = 'bds-carousel-card-list__card';
|
|
|
|
/**
|
|
* Props for the CarouselCardList pattern component
|
|
*/
|
|
export interface CarouselCardListProps extends React.ComponentPropsWithoutRef<'section'> {
|
|
/** Color variant of the cards */
|
|
variant?: 'neutral' | 'green';
|
|
/** Color variant of the navigation buttons (independent of card color). Defaults to 'neutral'. Derived from Button color prop. */
|
|
buttonVariant?: ButtonProps['color'] | 'neutral';
|
|
/** Section heading text */
|
|
heading: React.ReactNode;
|
|
/** Section description text */
|
|
description: React.ReactNode;
|
|
/** Array of card configurations */
|
|
cards: readonly CarouselCardConfig[];
|
|
}
|
|
|
|
/**
|
|
* Generates a stable key for a card based on its properties.
|
|
*/
|
|
const getCardKey = (card: CarouselCardConfig, index: number): string | number => {
|
|
if (card.href) return card.href;
|
|
if (card.title) return `${card.title}-${index}`;
|
|
return index;
|
|
};
|
|
|
|
/**
|
|
* CarouselCardList Pattern Component
|
|
*
|
|
* A horizontal scrolling carousel that displays CardOffgrid components.
|
|
* Features navigation buttons that scroll cards in/out of view.
|
|
* The navigation button colors can be set independently of the card colors
|
|
* using the `buttonVariant` prop.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <CarouselCardList
|
|
* variant="neutral"
|
|
* buttonVariant="green"
|
|
* heading="Why Choose Our Platform"
|
|
* description="Discover the benefits of our solution."
|
|
* cards={[
|
|
* { icon: <Icon />, title: "Feature 1", description: "..." },
|
|
* { icon: <Icon />, title: "Feature 2", description: "..." },
|
|
* ]}
|
|
* />
|
|
* ```
|
|
*/
|
|
export const CarouselCardList = React.forwardRef<HTMLElement, CarouselCardListProps>(
|
|
(props, ref) => {
|
|
const { variant = 'neutral', buttonVariant = 'neutral', heading, description, cards, className, ...rest } = props;
|
|
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const [canScrollPrev, setCanScrollPrev] = useState(false);
|
|
const [canScrollNext, setCanScrollNext] = useState(true);
|
|
|
|
// Check scroll position and update button states
|
|
const updateScrollButtons = useCallback(() => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
|
|
const { scrollLeft, scrollWidth, clientWidth } = container;
|
|
setCanScrollPrev(scrollLeft > 0);
|
|
setCanScrollNext(scrollLeft + clientWidth < scrollWidth - 1);
|
|
}, []);
|
|
|
|
// Initialize and listen for scroll events
|
|
useEffect(() => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
|
|
updateScrollButtons();
|
|
container.addEventListener('scroll', updateScrollButtons, { passive: true });
|
|
window.addEventListener('resize', updateScrollButtons);
|
|
|
|
return () => {
|
|
container.removeEventListener('scroll', updateScrollButtons);
|
|
window.removeEventListener('resize', updateScrollButtons);
|
|
};
|
|
}, [updateScrollButtons, cards.length]);
|
|
|
|
// Scroll by one card width
|
|
const scroll = useCallback((direction: 'prev' | 'next') => {
|
|
const container = scrollContainerRef.current;
|
|
if (!container) return;
|
|
|
|
// Get the first card to determine scroll amount
|
|
const card = container.querySelector(`.${CARD_CLASS_NAME}`) as HTMLElement;
|
|
if (!card) return;
|
|
|
|
const cardWidth = card.offsetWidth;
|
|
const gap = 8; // 8px gap between cards
|
|
const scrollAmount = cardWidth + gap;
|
|
|
|
container.scrollBy({
|
|
left: direction === 'next' ? scrollAmount : -scrollAmount,
|
|
behavior: 'smooth',
|
|
});
|
|
}, []);
|
|
|
|
// Early return for empty cards
|
|
if (cards.length === 0) {
|
|
console.warn('CarouselCardList: No cards provided');
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<section
|
|
ref={ref}
|
|
className={clsx('bds-carousel-card-list', `bds-carousel-card-list--${variant}`, className)}
|
|
{...rest}
|
|
>
|
|
{/* Header with title, description, and navigation buttons */}
|
|
<div className="bds-carousel-card-list__header">
|
|
<div className="bds-carousel-card-list__header-content">
|
|
<h2 className="bds-carousel-card-list__heading h-md">{heading}</h2>
|
|
<p className="bds-carousel-card-list__description body-l">{description}</p>
|
|
</div>
|
|
<div className="bds-carousel-card-list__nav">
|
|
<CarouselButton
|
|
direction="prev"
|
|
variant={buttonVariant}
|
|
disabled={!canScrollPrev}
|
|
onClick={() => scroll('prev')}
|
|
aria-label="Previous cards"
|
|
/>
|
|
<CarouselButton
|
|
direction="next"
|
|
variant={buttonVariant}
|
|
disabled={!canScrollNext}
|
|
onClick={() => scroll('next')}
|
|
aria-label="Next cards"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cards scroll container - full bleed */}
|
|
<div className="bds-carousel-card-list__track-wrapper">
|
|
<div
|
|
ref={scrollContainerRef}
|
|
className="bds-carousel-card-list__track"
|
|
role="region"
|
|
aria-label="Card carousel"
|
|
tabIndex={0}
|
|
>
|
|
{cards.map((card, index) => (
|
|
<div key={getCardKey(card, index)} className={CARD_CLASS_NAME}>
|
|
<CardOffgrid {...card} variant={variant} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
);
|
|
|
|
CarouselCardList.displayName = 'CarouselCardList';
|
|
|
|
/**
|
|
* Props for the CarouselButton component
|
|
*/
|
|
interface CarouselButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
/** Arrow direction */
|
|
direction: 'prev' | 'next';
|
|
/** Color variant (independent of card color) */
|
|
variant: 'neutral' | 'green' | 'black';
|
|
}
|
|
|
|
/**
|
|
* Internal CarouselButton component for navigation
|
|
*/
|
|
const CarouselButton: React.FC<CarouselButtonProps> = ({
|
|
direction,
|
|
variant,
|
|
disabled,
|
|
className,
|
|
...buttonProps
|
|
}) => {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={clsx(
|
|
'bds-carousel-card-list__button',
|
|
`bds-carousel-card-list__button--${direction}`,
|
|
`bds-carousel-card-list__button--${variant}`,
|
|
{ 'bds-carousel-card-list__button--disabled': disabled },
|
|
className
|
|
)}
|
|
disabled={disabled}
|
|
{...buttonProps}
|
|
>
|
|
{direction === 'prev' ? <CarouselArrowIconLeft /> : <CarouselArrowIconRight />}
|
|
</button>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* SVG Arrow Icon for carousel navigation - Right arrow
|
|
*/
|
|
const CarouselArrowIconRight: React.FC = () => (
|
|
<svg
|
|
className="bds-carousel-card-list__arrow-icon"
|
|
width="18"
|
|
height="16"
|
|
viewBox="0 0 18 16"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M9.33387 1.33461L15.9999 8.00058L9.33387 14.6666M15.9982 7.99893L-0.000149269 7.99893"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeMiterlimit="10"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
/**
|
|
* SVG Arrow Icon for carousel navigation - Left arrow
|
|
*/
|
|
const CarouselArrowIconLeft: React.FC = () => (
|
|
<svg
|
|
className="bds-carousel-card-list__arrow-icon"
|
|
width="18"
|
|
height="15"
|
|
viewBox="0 0 18 15"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M7.72667 0.530285L1.0607 7.19626L7.72667 13.8622M1.06235 7.19461L17.0607 7.19461"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
strokeMiterlimit="10"
|
|
/>
|
|
</svg>
|
|
);
|
|
|
|
export default CarouselCardList;
|
|
|