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