mirror of https://github.com/mastodon/mastodon
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
245 lines
6.5 KiB
TypeScript
245 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
};
|