Skip to content

Commit

Permalink
OPHJOD-1160: Create CardCarousel component
Browse files Browse the repository at this point in the history
  • Loading branch information
juhaniko committed Jan 10, 2025
1 parent c34cace commit 215e293
Show file tree
Hide file tree
Showing 3 changed files with 202 additions and 0 deletions.
48 changes: 48 additions & 0 deletions lib/components/CardCarousel/CardCarousel.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from '@storybook/react';
import { MediaCard } from '../MediaCard/MediaCard';
import { CardCarousel } from './CardCarousel';

const meta = {
title: 'Cards/CardCarousel',
component: CardCarousel,
tags: ['autodocs'],
} satisfies Meta<typeof CardCarousel>;

export default meta;

type Story = StoryObj<typeof meta>;

const design = {
type: 'figma',
url: 'https://www.figma.com/design/6M2LrpSCcB0thlFDaQAI2J/cx_jod_client?node-id=7031-3025&t=hppCWVuC76hePJaZ-4',
};
export const Default: Story = {
parameters: {
design,
docs: {
description: {
story: 'This is a carousel component for displaying MediaCards.',
},
},
},
args: {
items: Array.from({ length: 15 }, (_, i) => ({
id: `card-${i + 1}`,
component: (
<MediaCard
label={`Card ${i + 1}`}
description={`Description ${i + 1}`}
imageSrc="https://images.unsplash.com/photo-1523464862212-d6631d073194?q=80&w=260"
imageAlt={`Image ${i + 1}`}
tags={['Lorem', 'Ipsum', 'Dolor']}
/>
),
})),
itemWidth: 260,
translations: {
nextTrigger: 'Next page',
prevTrigger: 'Previous page',
indicator: (idx: number) => `Go to page ${idx + 1}`,
},
},
};
153 changes: 153 additions & 0 deletions lib/components/CardCarousel/CardCarousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import React from 'react';
import { MdChevronLeft, MdChevronRight } from 'react-icons/md';

export interface CarouselItem {
/** Id to be used as key during iteration */
id: string;
/** Component to be rendered in the carousel */
component: React.ReactNode;
}

export interface CardCarouselProps {
/** Items to show in the carousel */
items?: CarouselItem[];
/** Width of each item in the carousel */
itemWidth: number;
/** Translations for accessibility */
translations: {
nextTrigger: string;
prevTrigger: string;
indicator: (index: number) => string;
};
}
export const CardCarousel = ({ items = [], translations, itemWidth }: CardCarouselProps) => {
const containerRef = React.createRef<HTMLUListElement>();
const GAP = 16;
const [itemsPerPage, setItemsPerPage] = React.useState(1);
const [pageNr, setPageNr] = React.useState(0);
const [pageCount, setPageCount] = React.useState(0);
const [isFirstPage, setIsFirstPage] = React.useState(false);
const [isLastPage, setIsLastPage] = React.useState(false);
const getPageCount = React.useCallback(() => Math.ceil(items.length / itemsPerPage), [itemsPerPage, items.length]);

React.useEffect(() => {
const { current } = containerRef;
if (current) {
current.scrollTo({ left: pageNr * itemsPerPage * (itemWidth + GAP), behavior: 'smooth' });
}
}, [itemsPerPage, containerRef, itemWidth, pageNr]);

const goToNextPage = () => {
if (!isLastPage) {
setPageNr(pageNr + 1);
}
};
const goToPreviousPage = () => {
if (!isFirstPage) {
setPageNr(pageNr - 1);
}
};

const goToPage = (page: number) => () => {
setPageNr(page);
};

const handleEnterPress = (callback: () => void) => (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
callback();
}
};

React.useEffect(() => {
setPageCount(getPageCount());
setIsFirstPage(pageNr === 0);
setIsLastPage(pageNr === pageCount - 1);
}, [itemsPerPage, items.length, pageNr, pageCount, getPageCount]);

React.useEffect(() => {
const { current } = containerRef;

const handleResize = (entries: ResizeObserverEntry[]) => {
for (const entry of entries) {
if (entry.target == current) {
setItemsPerPage(Math.max(Math.floor(entry.contentRect.width / (itemWidth + GAP)), 1));
setPageCount(getPageCount());
setPageNr(Math.min(Math.max(0, pageNr), getPageCount() - 1));
}
}
};

const resizeObserver = new ResizeObserver(handleResize);

if (current) {
resizeObserver.observe(current);
}

return () => {
if (current) {
resizeObserver.unobserve(current);
}
};
}, [itemsPerPage, getPageCount, itemWidth, items.length, pageNr, containerRef]);

return (
<>
<ul className="ds-flex ds-flex-row ds-gap-5 ds-overflow-hidden ds-p-3" ref={containerRef}>
{items.map((item, index) => {
// Change the page according to focused item during tab navigation
const onFocus = () => {
const pageWhereFocusedItemIs = Math.floor(index / itemsPerPage);
const focusedItemIsOutsideCurrentPage = pageWhereFocusedItemIs !== pageNr;

if (focusedItemIsOutsideCurrentPage) {
setPageNr(pageWhereFocusedItemIs);
}
};
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
<li key={item.id} style={{ width: itemWidth }} tabIndex={0} onFocus={onFocus}>
{item.component}
</li>
);
})}
</ul>
<div className="ds-flex ds-flex-row ds-gap-2 ds-justify-between ds-items-center ds-p-3">
<button
onClick={goToPreviousPage}
onKeyDown={handleEnterPress(goToPreviousPage)}
aria-label={translations.prevTrigger}
disabled={isFirstPage}
>
<span className="ds-size-8 ds-flex ds-justify-center ds-items-center ds-bg-bg-gray-2 ds-rounded-full">
<MdChevronLeft size={24} className={isFirstPage ? 'ds-text-inactive-gray' : 'ds-text-black'} />
</span>
</button>

<div className="ds-flex ds-flex-row ds-gap-2 ds-justify-center">
{Array.from({ length: Math.max(1, pageCount) }, (_, page) => (
<button
type="button"
key={page}
className={`ds-rounded-full ds-size-4 ${pageNr === page ? 'ds-bg-accent' : 'ds-bg-[#d4d4d4]'}`}
onClick={goToPage(page)}
onKeyDown={handleEnterPress(goToPage(page))}
>
<span className="ds-sr-only">{translations.indicator(page)}</span>
</button>
))}
</div>

<button
onClick={goToNextPage}
onKeyDown={handleEnterPress(goToNextPage)}
aria-label={translations.nextTrigger}
disabled={isLastPage}
>
<span className="ds-size-8 ds-flex ds-justify-center ds-items-center ds-bg-bg-gray-2 ds-rounded-full">
<MdChevronRight size={24} className={isLastPage ? 'ds-text-inactive-gray' : 'ds-text-black'} />
</span>
</button>
</div>
</>
);
};
1 change: 1 addition & 0 deletions lib/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { useMediaQueries } from './hooks/useMediaQueries';
export { Accordion } from './components/Accordion/Accordion';
export { ActiveIndicator } from './components/ActiveIndicator/ActiveIndicator';
export { Button } from './components/Button/Button';
export { CardCarousel } from './components/CardCarousel/CardCarousel';
export { Checkbox } from './components/Checkbox/Checkbox';
export { Combobox } from './components/Combobox/Combobox';
export { ConfirmDialog } from './components/ConfirmDialog/ConfirmDialog';
Expand Down

0 comments on commit 215e293

Please sign in to comment.