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