mirror of https://github.com/mastodon/mastodon
Refactor carousel components (#36425)
Co-authored-by: diondiondion <mail@diondiondion.com>dion/implement-css-theme-tokens
parent
2a9c7d2b9e
commit
e7cd5a430e
@ -0,0 +1,126 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { fn, userEvent, expect } from 'storybook/test';
|
||||
|
||||
import type { CarouselProps } from './index';
|
||||
import { Carousel } from './index';
|
||||
|
||||
interface TestSlideProps {
|
||||
id: number;
|
||||
text: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const TestSlide: FC<TestSlideProps & { active: boolean }> = ({
|
||||
active,
|
||||
text,
|
||||
color,
|
||||
}) => (
|
||||
<div
|
||||
className='test-slide'
|
||||
style={{
|
||||
backgroundColor: active ? color : undefined,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
const slides: TestSlideProps[] = [
|
||||
{
|
||||
id: 1,
|
||||
text: 'first',
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
text: 'second',
|
||||
color: 'pink',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
text: 'third',
|
||||
color: 'orange',
|
||||
},
|
||||
];
|
||||
|
||||
type StoryProps = Pick<
|
||||
CarouselProps<TestSlideProps>,
|
||||
'items' | 'renderItem' | 'emptyFallback' | 'onChangeSlide'
|
||||
>;
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Carousel',
|
||||
args: {
|
||||
items: slides,
|
||||
renderItem(item, active) {
|
||||
return <TestSlide {...item} active={active} key={item.id} />;
|
||||
},
|
||||
onChangeSlide: fn(),
|
||||
emptyFallback: 'No slides available',
|
||||
},
|
||||
render(args) {
|
||||
return (
|
||||
<>
|
||||
<Carousel {...args} />
|
||||
<style>
|
||||
{`.test-slide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
min-height: 100px;
|
||||
transition: background-color 0.3s;
|
||||
background-color: black;
|
||||
}`}
|
||||
</style>
|
||||
</>
|
||||
);
|
||||
},
|
||||
argTypes: {
|
||||
emptyFallback: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
tags: ['test'],
|
||||
} satisfies Meta<StoryProps>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
async play({ args, canvas }) {
|
||||
const nextButton = await canvas.findByRole('button', { name: /next/i });
|
||||
const slides = await canvas.findAllByRole('group');
|
||||
await expect(slides).toHaveLength(slides.length);
|
||||
|
||||
await userEvent.click(nextButton);
|
||||
await expect(args.onChangeSlide).toHaveBeenCalledWith(1, slides[1]);
|
||||
|
||||
await userEvent.click(nextButton);
|
||||
await expect(args.onChangeSlide).toHaveBeenCalledWith(2, slides[2]);
|
||||
|
||||
// Wrap around
|
||||
await userEvent.click(nextButton);
|
||||
await expect(args.onChangeSlide).toHaveBeenCalledWith(0, slides[0]);
|
||||
},
|
||||
};
|
||||
|
||||
export const DifferentHeights: Story = {
|
||||
args: {
|
||||
items: slides.map((props, index) => ({
|
||||
...props,
|
||||
styles: { height: 100 + index * 100 },
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
export const NoSlides: Story = {
|
||||
args: {
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,244 @@
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type {
|
||||
ComponentPropsWithoutRef,
|
||||
ComponentType,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { usePrevious } from '@dnd-kit/utilities';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
|
||||
import type { CarouselPaginationProps } from './pagination';
|
||||
import { CarouselPagination } from './pagination';
|
||||
|
||||
import './styles.scss';
|
||||
|
||||
const defaultMessages = defineMessages({
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
current: {
|
||||
id: 'carousel.current',
|
||||
defaultMessage: '<sr>Slide</sr> {current, number} / {max, number}',
|
||||
},
|
||||
slide: {
|
||||
id: 'carousel.slide',
|
||||
defaultMessage: 'Slide {current, number} of {max, number}',
|
||||
},
|
||||
});
|
||||
|
||||
export type MessageKeys = keyof typeof defaultMessages;
|
||||
|
||||
export interface CarouselSlideProps {
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
export type RenderSlideFn<
|
||||
SlideProps extends CarouselSlideProps = CarouselSlideProps,
|
||||
> = (item: SlideProps, active: boolean, index: number) => ReactElement;
|
||||
|
||||
export interface CarouselProps<
|
||||
SlideProps extends CarouselSlideProps = CarouselSlideProps,
|
||||
> {
|
||||
items: SlideProps[];
|
||||
renderItem: RenderSlideFn<SlideProps>;
|
||||
onChangeSlide?: (index: number, ref: Element) => void;
|
||||
paginationComponent?: ComponentType<CarouselPaginationProps> | null;
|
||||
paginationProps?: Partial<CarouselPaginationProps>;
|
||||
messages?: Record<MessageKeys, MessageDescriptor>;
|
||||
emptyFallback?: ReactNode;
|
||||
classNamePrefix?: string;
|
||||
slideClassName?: string;
|
||||
}
|
||||
|
||||
export const Carousel = <
|
||||
SlideProps extends CarouselSlideProps = CarouselSlideProps,
|
||||
>({
|
||||
items,
|
||||
renderItem,
|
||||
onChangeSlide,
|
||||
paginationComponent: Pagination = CarouselPagination,
|
||||
paginationProps = {},
|
||||
messages = defaultMessages,
|
||||
children,
|
||||
emptyFallback = null,
|
||||
className,
|
||||
classNamePrefix = 'carousel',
|
||||
slideClassName,
|
||||
...wrapperProps
|
||||
}: CarouselProps<SlideProps> & ComponentPropsWithoutRef<'div'>) => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
const slide = wrapperRef.current?.children[newIndex];
|
||||
if (slide) {
|
||||
setCurrentSlideHeight(slide.scrollHeight);
|
||||
onChangeSlide?.(newIndex, slide);
|
||||
if (slide instanceof HTMLElement) {
|
||||
slide.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
return newIndex;
|
||||
});
|
||||
},
|
||||
[items.length, onChangeSlide],
|
||||
);
|
||||
|
||||
// Handle slide heights
|
||||
const [currentSlideHeight, setCurrentSlideHeight] = useState(
|
||||
wrapperRef.current?.scrollHeight ?? 0,
|
||||
);
|
||||
const previousSlideHeight = usePrevious(currentSlideHeight);
|
||||
const observerRef = useRef<ResizeObserver>(
|
||||
new ResizeObserver(() => {
|
||||
handleSlideChange(0);
|
||||
}),
|
||||
);
|
||||
const wrapperStyles = useSpring({
|
||||
x: `-${slideIndex * 100}%`,
|
||||
height: currentSlideHeight,
|
||||
// Don't animate from zero to the height of the initial slide
|
||||
immediate: !previousSlideHeight,
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
// Update slide height when the component mounts
|
||||
if (currentSlideHeight === 0) {
|
||||
handleSlideChange(0);
|
||||
}
|
||||
}, [currentSlideHeight, handleSlideChange]);
|
||||
|
||||
// Handle swiping animations
|
||||
const bind = useDrag(
|
||||
({ swipe: [swipeX] }) => {
|
||||
handleSlideChange(swipeX * -1); // Invert swipe as swiping left loads the next slide.
|
||||
},
|
||||
{ pointer: { capture: false } },
|
||||
);
|
||||
const handlePrev = useCallback(() => {
|
||||
handleSlideChange(-1);
|
||||
}, [handleSlideChange]);
|
||||
const handleNext = useCallback(() => {
|
||||
handleSlideChange(1);
|
||||
}, [handleSlideChange]);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
if (items.length === 0) {
|
||||
return emptyFallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...bind()}
|
||||
aria-roledescription='carousel'
|
||||
role='region'
|
||||
className={classNames(classNamePrefix, className)}
|
||||
{...wrapperProps}
|
||||
>
|
||||
<div className={`${classNamePrefix}__header`}>
|
||||
{children}
|
||||
{Pagination && items.length > 1 && (
|
||||
<Pagination
|
||||
current={slideIndex}
|
||||
max={items.length}
|
||||
onNext={handleNext}
|
||||
onPrev={handlePrev}
|
||||
className={`${classNamePrefix}__pagination`}
|
||||
messages={messages}
|
||||
{...paginationProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<animated.div
|
||||
className={`${classNamePrefix}__slides`}
|
||||
ref={wrapperRef}
|
||||
style={wrapperStyles}
|
||||
>
|
||||
{items.map((itemsProps, index) => (
|
||||
<CarouselSlideWrapper<SlideProps>
|
||||
item={itemsProps}
|
||||
renderItem={renderItem}
|
||||
observer={observerRef.current}
|
||||
index={index}
|
||||
key={`slide-${itemsProps.id}`}
|
||||
className={classNames(`${classNamePrefix}__slide`, slideClassName, {
|
||||
active: index === slideIndex,
|
||||
})}
|
||||
active={index === slideIndex}
|
||||
label={intl.formatMessage(messages.slide, {
|
||||
current: index + 1,
|
||||
max: items.length,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type CarouselSlideWrapperProps<SlideProps extends CarouselSlideProps> = {
|
||||
observer: ResizeObserver;
|
||||
className: string;
|
||||
active: boolean;
|
||||
item: SlideProps;
|
||||
index: number;
|
||||
label: string;
|
||||
} & Pick<CarouselProps<SlideProps>, 'renderItem'>;
|
||||
|
||||
const CarouselSlideWrapper = <SlideProps extends CarouselSlideProps>({
|
||||
observer,
|
||||
className,
|
||||
active,
|
||||
renderItem,
|
||||
item,
|
||||
index,
|
||||
label,
|
||||
}: CarouselSlideWrapperProps<SlideProps>) => {
|
||||
const handleRef = useCallback(
|
||||
(instance: HTMLDivElement | null) => {
|
||||
if (instance) {
|
||||
observer.observe(instance);
|
||||
}
|
||||
},
|
||||
[observer],
|
||||
);
|
||||
|
||||
const children = useMemo(
|
||||
() => renderItem(item, active, index),
|
||||
[renderItem, item, active, index],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={handleRef}
|
||||
className={className}
|
||||
role='group'
|
||||
aria-roledescription='slide'
|
||||
aria-label={label}
|
||||
inert={active ? undefined : ''}
|
||||
tabIndex={-1}
|
||||
data-index={index}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,54 @@
|
||||
import type { FC, MouseEventHandler } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import { 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';
|
||||
|
||||
import type { MessageKeys } from './index';
|
||||
|
||||
export interface CarouselPaginationProps {
|
||||
onNext: MouseEventHandler;
|
||||
onPrev: MouseEventHandler;
|
||||
current: number;
|
||||
max: number;
|
||||
className?: string;
|
||||
messages: Record<MessageKeys, MessageDescriptor>;
|
||||
}
|
||||
|
||||
export const CarouselPagination: FC<CarouselPaginationProps> = ({
|
||||
onNext,
|
||||
onPrev,
|
||||
current,
|
||||
max,
|
||||
className = '',
|
||||
messages,
|
||||
}) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
.carousel {
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
touch-action: pan-y;
|
||||
|
||||
&__header {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__slides {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
&__slide {
|
||||
flex: 0 0 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
declare namespace React {
|
||||
interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
|
||||
// Add inert attribute support, which is only in React 19.2. See: https://github.com/facebook/react/pull/24730
|
||||
inert?: '';
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue