mirror of
https://github.com/mastodon/mastodon.git
synced 2025-10-05 16:42:47 +00:00
Starts work on generic carousel solution
Some checks failed
Chromatic / Run Chromatic (push) Has been cancelled
Some checks failed
Chromatic / Run Chromatic (push) Has been cancelled
This commit is contained in:
parent
bf860c39c2
commit
6a68fb4458
|
@ -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: {},
|
||||
};
|
108
app/javascript/mastodon/components/carousel/index.tsx
Normal file
108
app/javascript/mastodon/components/carousel/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
67
app/javascript/mastodon/components/carousel/pagination.tsx
Normal file
67
app/javascript/mastodon/components/carousel/pagination.tsx
Normal 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>
|
||||
);
|
||||
};
|
Loading…
Reference in New Issue
Block a user