Starts work on generic carousel solution
Some checks failed
Chromatic / Run Chromatic (push) Has been cancelled

This commit is contained in:
ChaosExAnima 2025-07-31 15:25:08 +02:00
parent bf860c39c2
commit 6a68fb4458
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
3 changed files with 223 additions and 0 deletions

View File

@ -0,0 +1,48 @@
import type { FC } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import type { CarouselSlideProps } from '.';
import { Carousel } from '.';
interface TestSlideProps {
id: number;
text: string;
}
const TestSlide: FC<TestSlideProps & CarouselSlideProps> = ({
active,
text,
}) => {
return <div style={{ backgroundColor: active ? 'red' : 'blue' }}>{text}</div>;
};
const slides: TestSlideProps[] = [
{
id: 1,
text: 'first',
},
{
id: 2,
text: 'second',
},
];
const meta = {
title: 'Components/Carousel',
component: Carousel,
args: {
items: slides,
slideComponent: TestSlide,
},
argTypes: {},
tags: ['test'],
} satisfies Meta<typeof Carousel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {},
};

View File

@ -0,0 +1,108 @@
import { useCallback, useId, useRef, useState } from 'react';
import type { ComponentPropsWithoutRef, ComponentType, ReactNode } from 'react';
import { animated, useSpring } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import type { CarouselPaginationProps } from './pagination';
import { CarouselPagination } from './pagination';
export interface CarouselSlideProps {
id: string | number;
active: boolean;
}
type CarouselSlideComponent<SlideProps> = ComponentType<
SlideProps & CarouselSlideProps
>;
interface CarouselProps<SlideProps> extends ComponentPropsWithoutRef<'div'> {
items: SlideProps[];
slideComponent: CarouselSlideComponent<SlideProps>;
pageComponent?: ComponentType<CarouselPaginationProps>;
slidesWrapperClassName?: string;
emptyFallback?: ReactNode;
}
export const Carousel = <SlideProps extends CarouselSlideProps>({
items,
pageComponent: Pagination = CarouselPagination,
slideComponent: Slide,
children,
emptyFallback = null,
...wrapperProps
}: CarouselProps<SlideProps>) => {
const accessibilityId = useId();
// Handle slide change
const [slideIndex, setSlideIndex] = useState(0);
const wrapperRef = useRef<HTMLDivElement>(null);
const handleSlideChange = useCallback(
(direction: number) => {
setSlideIndex((prev) => {
const max = items.length - 1;
let newIndex = prev + direction;
if (newIndex < 0) {
newIndex = max;
} else if (newIndex > max) {
newIndex = 0;
}
return newIndex;
});
},
[items.length],
);
const wrapperStyles = useSpring({
x: `-${slideIndex * 100}%`,
});
// Handle swiping animations
const bind = useDrag(({ swipe: [swipeX] }) => {
handleSlideChange(swipeX * -1); // Invert swipe as swiping left loads the next slide.
});
const handlePrev = useCallback(() => {
handleSlideChange(-1);
}, [handleSlideChange]);
const handleNext = useCallback(() => {
handleSlideChange(1);
}, [handleSlideChange]);
if (!items.length) {
return emptyFallback;
}
return (
<div
{...bind()}
aria-roledescription='carousel'
aria-labelledby={`${accessibilityId}-title`}
role='region'
{...wrapperProps}
>
{children}
<Pagination
current={slideIndex}
max={items.length}
onNext={handleNext}
onPrev={handlePrev}
/>
<animated.div
className='carousel__slides'
ref={wrapperRef}
style={wrapperStyles}
aria-atomic='false'
aria-live='polite'
>
{items.map(({ id, ...props }, index) => (
<Slide
{...props}
id={id}
key={`slide-${id}`}
data-index={index}
active={index === slideIndex}
/>
))}
</animated.div>
</div>
);
};

View File

@ -0,0 +1,67 @@
import type { FC, MouseEventHandler } from 'react';
import type { MessageDescriptor } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import { IconButton } from '../icon_button';
export interface CarouselPaginationProps {
onNext: MouseEventHandler;
onPrev: MouseEventHandler;
current: number;
max: number;
className?: string;
messages?: Record<MessageKeys, MessageDescriptor>;
}
export const defaultMessages = defineMessages({
previous: { id: 'carousel.previous', defaultMessage: 'Previous' },
next: { id: 'carousel.next', defaultMessage: 'Next' },
slide: {
id: 'carousel.slide',
defaultMessage: '{index} of {total}',
},
current: {
id: 'carousel.current',
defaultMessage: '<sr>Slide</sr> {current, number} / {max, number}',
},
});
type MessageKeys = keyof typeof defaultMessages;
export const CarouselPagination: FC<CarouselPaginationProps> = ({
onNext,
onPrev,
current,
max,
className = '',
messages = defaultMessages,
}) => {
const intl = useIntl();
return (
<div className={className}>
<IconButton
title={intl.formatMessage(messages.previous)}
icon='chevron-left'
iconComponent={ChevronLeftIcon}
onClick={onPrev}
/>
<span aria-live='polite'>
{intl.formatMessage(messages.current, {
current: current + 1,
max,
sr: (chunk) => <span className='sr-only'>{chunk}</span>,
})}
</span>
<IconButton
title={intl.formatMessage(messages.next)}
icon='chevron-right'
iconComponent={ChevronRightIcon}
onClick={onNext}
/>
</div>
);
};