mirror of
https://github.com/glitch-soc/mastodon.git
synced 2025-12-13 07:49:29 +00:00
Refactor carousel components (#36425)
Co-authored-by: diondiondion <mail@diondiondion.com>
This commit is contained in:
126
app/javascript/mastodon/components/carousel/carousel.stories.tsx
Normal file
126
app/javascript/mastodon/components/carousel/carousel.stories.tsx
Normal file
@@ -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: [],
|
||||
},
|
||||
};
|
||||
244
app/javascript/mastodon/components/carousel/index.tsx
Normal file
244
app/javascript/mastodon/components/carousel/index.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
54
app/javascript/mastodon/components/carousel/pagination.tsx
Normal file
54
app/javascript/mastodon/components/carousel/pagination.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
28
app/javascript/mastodon/components/carousel/styles.scss
Normal file
28
app/javascript/mastodon/components/carousel/styles.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,43 @@
|
||||
import type { ComponentPropsWithRef } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useId,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useId } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
||||
import type { AnimatedProps } from '@react-spring/web';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
|
||||
import { expandAccountFeaturedTimeline } from '@/mastodon/actions/timelines';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { StatusQuoteManager } from '@/mastodon/components/status_quoted';
|
||||
import { usePrevious } from '@/mastodon/hooks/usePrevious';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import {
|
||||
createAppSelector,
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from '@/mastodon/store';
|
||||
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
|
||||
|
||||
import { Carousel } from './carousel';
|
||||
|
||||
const pinnedStatusesSelector = createAppSelector(
|
||||
[
|
||||
(state, accountId: string, tagged?: string) =>
|
||||
(state.timelines as ImmutableMap<string, unknown>).getIn(
|
||||
[`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'],
|
||||
ImmutableList(),
|
||||
) as ImmutableList<string>,
|
||||
],
|
||||
(items) => items.toArray().map((id) => ({ id })),
|
||||
);
|
||||
|
||||
const messages = defineMessages({
|
||||
previous: { id: 'featured_carousel.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'featured_carousel.next', defaultMessage: 'Next' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
current: {
|
||||
id: 'featured_carousel.current',
|
||||
defaultMessage: '<sr>Post</sr> {current, number} / {max, number}',
|
||||
},
|
||||
slide: {
|
||||
id: 'featured_carousel.slide',
|
||||
defaultMessage: '{index} of {total}',
|
||||
defaultMessage: 'Post {current, number} of {max, number}',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,7 +45,6 @@ export const FeaturedCarousel: React.FC<{
|
||||
accountId: string;
|
||||
tagged?: string;
|
||||
}> = ({ accountId, tagged }) => {
|
||||
const intl = useIntl();
|
||||
const accessibilityId = useId();
|
||||
|
||||
// Load pinned statuses
|
||||
@@ -50,175 +54,37 @@ export const FeaturedCarousel: React.FC<{
|
||||
void dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
|
||||
}
|
||||
}, [accountId, dispatch, tagged]);
|
||||
const pinnedStatuses = useAppSelector(
|
||||
(state) =>
|
||||
(state.timelines as ImmutableMap<string, unknown>).getIn(
|
||||
[`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'],
|
||||
ImmutableList(),
|
||||
) as ImmutableList<string>,
|
||||
const pinnedStatuses = useAppSelector((state) =>
|
||||
pinnedStatusesSelector(state, accountId, tagged),
|
||||
);
|
||||
|
||||
// Handle slide change
|
||||
const [slideIndex, setSlideIndex] = useState(0);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const handleSlideChange = useCallback(
|
||||
(direction: number) => {
|
||||
setSlideIndex((prev) => {
|
||||
const max = pinnedStatuses.size - 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);
|
||||
}
|
||||
return newIndex;
|
||||
});
|
||||
},
|
||||
[pinnedStatuses.size],
|
||||
const renderSlide = useCallback(
|
||||
({ id }: { id: string }) => (
|
||||
<StatusQuoteManager id={id} contextType='account' withCounters />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// 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.
|
||||
});
|
||||
const handlePrev = useCallback(() => {
|
||||
handleSlideChange(-1);
|
||||
}, [handleSlideChange]);
|
||||
const handleNext = useCallback(() => {
|
||||
handleSlideChange(1);
|
||||
}, [handleSlideChange]);
|
||||
|
||||
if (!accountId || pinnedStatuses.isEmpty()) {
|
||||
if (!accountId || pinnedStatuses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='featured-carousel'
|
||||
{...bind()}
|
||||
aria-roledescription='carousel'
|
||||
<Carousel
|
||||
items={pinnedStatuses}
|
||||
renderItem={renderSlide}
|
||||
aria-labelledby={`${accessibilityId}-title`}
|
||||
role='region'
|
||||
classNamePrefix='featured-carousel'
|
||||
messages={messages}
|
||||
>
|
||||
<div className='featured-carousel__header'>
|
||||
<h4
|
||||
className='featured-carousel__title'
|
||||
id={`${accessibilityId}-title`}
|
||||
>
|
||||
<Icon id='thumb-tack' icon={PushPinIcon} />
|
||||
<FormattedMessage
|
||||
id='featured_carousel.header'
|
||||
defaultMessage='{count, plural, one {Pinned Post} other {Pinned Posts}}'
|
||||
values={{ count: pinnedStatuses.size }}
|
||||
/>
|
||||
</h4>
|
||||
{pinnedStatuses.size > 1 && (
|
||||
<>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.previous)}
|
||||
icon='chevron-left'
|
||||
iconComponent={ChevronLeftIcon}
|
||||
onClick={handlePrev}
|
||||
/>
|
||||
<span aria-live='polite'>
|
||||
<FormattedMessage
|
||||
id='featured_carousel.post'
|
||||
defaultMessage='Post'
|
||||
>
|
||||
{(text) => <span className='sr-only'>{text}</span>}
|
||||
</FormattedMessage>
|
||||
{slideIndex + 1} / {pinnedStatuses.size}
|
||||
</span>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.next)}
|
||||
icon='chevron-right'
|
||||
iconComponent={ChevronRightIcon}
|
||||
onClick={handleNext}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<animated.div
|
||||
className='featured-carousel__slides'
|
||||
ref={wrapperRef}
|
||||
style={wrapperStyles}
|
||||
aria-atomic='false'
|
||||
aria-live='polite'
|
||||
>
|
||||
{pinnedStatuses.map((statusId, index) => (
|
||||
<FeaturedCarouselItem
|
||||
key={`f-${statusId}`}
|
||||
data-index={index}
|
||||
aria-label={intl.formatMessage(messages.slide, {
|
||||
index: index + 1,
|
||||
total: pinnedStatuses.size,
|
||||
})}
|
||||
statusId={statusId}
|
||||
observer={observerRef.current}
|
||||
active={index === slideIndex}
|
||||
/>
|
||||
))}
|
||||
</animated.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FeaturedCarouselItemProps {
|
||||
statusId: string;
|
||||
active: boolean;
|
||||
observer: ResizeObserver;
|
||||
}
|
||||
|
||||
const FeaturedCarouselItem: React.FC<
|
||||
FeaturedCarouselItemProps & AnimatedProps<ComponentPropsWithRef<'div'>>
|
||||
> = ({ statusId, active, observer, ...props }) => {
|
||||
const handleRef = useCallback(
|
||||
(instance: HTMLDivElement | null) => {
|
||||
if (instance) {
|
||||
observer.observe(instance);
|
||||
}
|
||||
},
|
||||
[observer],
|
||||
);
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
className='featured-carousel__slide'
|
||||
// @ts-expect-error inert in not in this version of React
|
||||
inert={!active ? 'true' : undefined}
|
||||
aria-roledescription='slide'
|
||||
role='group'
|
||||
ref={handleRef}
|
||||
{...props}
|
||||
>
|
||||
<StatusQuoteManager id={statusId} contextType='account' withCounters />
|
||||
</animated.div>
|
||||
<h4 className='featured-carousel__title' id={`${accessibilityId}-title`}>
|
||||
<Icon id='thumb-tack' icon={PushPinIcon} />
|
||||
<FormattedMessage
|
||||
id='featured_carousel.header'
|
||||
defaultMessage='{count, plural, one {Pinned Post} other {Pinned Posts}}'
|
||||
values={{ count: pinnedStatuses.length }}
|
||||
/>
|
||||
</h4>
|
||||
</Carousel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,24 +15,24 @@ export interface IAnnouncement extends ApiAnnouncementJSON {
|
||||
|
||||
interface AnnouncementProps {
|
||||
announcement: IAnnouncement;
|
||||
selected: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export const Announcement: FC<AnnouncementProps> = ({
|
||||
announcement,
|
||||
selected,
|
||||
active,
|
||||
}) => {
|
||||
const [unread, setUnread] = useState(!announcement.read);
|
||||
useEffect(() => {
|
||||
// Only update `unread` marker once the announcement is out of view
|
||||
if (!selected && unread !== !announcement.read) {
|
||||
if (!active && unread !== !announcement.read) {
|
||||
setUnread(!announcement.read);
|
||||
}
|
||||
}, [announcement.read, selected, unread]);
|
||||
}, [announcement.read, active, unread]);
|
||||
|
||||
return (
|
||||
<AnimateEmojiProvider className='announcements__item'>
|
||||
<strong className='announcements__item__range'>
|
||||
<AnimateEmojiProvider>
|
||||
<strong className='announcements__range'>
|
||||
<FormattedMessage
|
||||
id='announcement.announcement'
|
||||
defaultMessage='Announcement'
|
||||
@@ -44,14 +44,14 @@ export const Announcement: FC<AnnouncementProps> = ({
|
||||
</strong>
|
||||
|
||||
<EmojiHTML
|
||||
className='announcements__item__content translate'
|
||||
className='announcements__content translate'
|
||||
htmlString={announcement.contentHtml}
|
||||
extraEmojis={announcement.emojis}
|
||||
/>
|
||||
|
||||
<ReactionsBar reactions={announcement.reactions} id={announcement.id} />
|
||||
|
||||
{unread && <span className='announcements__item__unread' />}
|
||||
{unread && <span className='announcements__unread' />}
|
||||
</AnimateEmojiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,63 +1,50 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import type { Map, List } from 'immutable';
|
||||
|
||||
import ReactSwipeableViews from 'react-swipeable-views';
|
||||
|
||||
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
||||
import type { RenderSlideFn } from '@/mastodon/components/carousel';
|
||||
import { Carousel } from '@/mastodon/components/carousel';
|
||||
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { mascot, reduceMotion } from '@/mastodon/initial_state';
|
||||
import { mascot } from '@/mastodon/initial_state';
|
||||
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
|
||||
import type { IAnnouncement } from './announcement';
|
||||
import { Announcement } from './announcement';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||
});
|
||||
|
||||
const announcementSelector = createAppSelector(
|
||||
[(state) => state.announcements as Map<string, List<Map<string, unknown>>>],
|
||||
(announcements) =>
|
||||
(announcements.get('items')?.toJS() as IAnnouncement[] | undefined) ?? [],
|
||||
((announcements.get('items')?.toJS() as IAnnouncement[] | undefined) ?? [])
|
||||
.map((announcement) => ({ announcement, id: announcement.id }))
|
||||
.toReversed(),
|
||||
);
|
||||
|
||||
export const Announcements: FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const announcements = useAppSelector(announcementSelector);
|
||||
const emojis = useAppSelector((state) => state.custom_emojis);
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
const handleChangeIndex = useCallback(
|
||||
(idx: number) => {
|
||||
setIndex(idx % announcements.length);
|
||||
},
|
||||
[announcements.length],
|
||||
const renderSlide: RenderSlideFn<{
|
||||
id: string;
|
||||
announcement: IAnnouncement;
|
||||
}> = useCallback(
|
||||
(item, active) => (
|
||||
<Announcement
|
||||
announcement={item.announcement}
|
||||
active={active}
|
||||
key={item.id}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
const handleNextIndex = useCallback(() => {
|
||||
setIndex((prevIndex) => (prevIndex + 1) % announcements.length);
|
||||
}, [announcements.length]);
|
||||
const handlePrevIndex = useCallback(() => {
|
||||
setIndex((prevIndex) =>
|
||||
prevIndex === 0 ? announcements.length - 1 : prevIndex - 1,
|
||||
);
|
||||
}, [announcements.length]);
|
||||
|
||||
if (announcements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='announcements'>
|
||||
<div className='announcements__root'>
|
||||
<img
|
||||
className='announcements__mastodon'
|
||||
alt=''
|
||||
@@ -65,48 +52,13 @@ export const Announcements: FC = () => {
|
||||
src={mascot ?? elephantUIPlane}
|
||||
/>
|
||||
|
||||
<div className='announcements__container'>
|
||||
<CustomEmojiProvider emojis={emojis}>
|
||||
<ReactSwipeableViews
|
||||
animateHeight
|
||||
animateTransitions={!reduceMotion}
|
||||
index={index}
|
||||
onChangeIndex={handleChangeIndex}
|
||||
>
|
||||
{announcements
|
||||
.map((announcement, idx) => (
|
||||
<Announcement
|
||||
key={announcement.id}
|
||||
announcement={announcement}
|
||||
selected={index === idx}
|
||||
/>
|
||||
))
|
||||
.reverse()}
|
||||
</ReactSwipeableViews>
|
||||
</CustomEmojiProvider>
|
||||
|
||||
{announcements.length > 1 && (
|
||||
<div className='announcements__pagination'>
|
||||
<IconButton
|
||||
disabled={announcements.length === 1}
|
||||
title={intl.formatMessage(messages.previous)}
|
||||
icon='chevron-left'
|
||||
iconComponent={ChevronLeftIcon}
|
||||
onClick={handlePrevIndex}
|
||||
/>
|
||||
<span>
|
||||
{index + 1} / {announcements.length}
|
||||
</span>
|
||||
<IconButton
|
||||
disabled={announcements.length === 1}
|
||||
title={intl.formatMessage(messages.next)}
|
||||
icon='chevron-right'
|
||||
iconComponent={ChevronRightIcon}
|
||||
onClick={handleNextIndex}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CustomEmojiProvider emojis={emojis}>
|
||||
<Carousel
|
||||
classNamePrefix='announcements'
|
||||
renderItem={renderSlide}
|
||||
items={announcements}
|
||||
/>
|
||||
</CustomEmojiProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -157,6 +157,8 @@
|
||||
"bundle_modal_error.close": "Close",
|
||||
"bundle_modal_error.message": "Something went wrong while loading this screen.",
|
||||
"bundle_modal_error.retry": "Try again",
|
||||
"carousel.current": "<sr>Slide</sr> {current, number} / {max, number}",
|
||||
"carousel.slide": "Slide {current, number} of {max, number}",
|
||||
"closed_registrations.other_server_instructions": "Since Mastodon is decentralized, you can create an account on another server and still interact with this one.",
|
||||
"closed_registrations_modal.description": "Creating an account on {domain} is currently not possible, but please keep in mind that you do not need an account specifically on {domain} to use Mastodon.",
|
||||
"closed_registrations_modal.find_another_server": "Find another server",
|
||||
@@ -360,11 +362,9 @@
|
||||
"explore.trending_links": "News",
|
||||
"explore.trending_statuses": "Posts",
|
||||
"explore.trending_tags": "Hashtags",
|
||||
"featured_carousel.current": "<sr>Post</sr> {current, number} / {max, number}",
|
||||
"featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}",
|
||||
"featured_carousel.next": "Next",
|
||||
"featured_carousel.post": "Post",
|
||||
"featured_carousel.previous": "Previous",
|
||||
"featured_carousel.slide": "{index} of {total}",
|
||||
"featured_carousel.slide": "Post {current, number} of {max, number}",
|
||||
"filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.",
|
||||
"filter_modal.added.context_mismatch_title": "Context mismatch!",
|
||||
"filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.",
|
||||
|
||||
@@ -1362,7 +1362,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.announcements__item__content {
|
||||
.announcements__content {
|
||||
overflow-wrap: break-word;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -8931,10 +8931,21 @@ noscript {
|
||||
}
|
||||
|
||||
.announcements {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
width: calc(100% - 124px);
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (max-width: (124px + 300px)) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__root {
|
||||
background: lighten($ui-base-color, 8%);
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
&__mastodon {
|
||||
width: 124px;
|
||||
@@ -8945,19 +8956,16 @@ noscript {
|
||||
}
|
||||
}
|
||||
|
||||
&__container {
|
||||
width: calc(100% - 124px);
|
||||
flex: 0 0 auto;
|
||||
position: relative;
|
||||
|
||||
@media screen and (max-width: (124px + 300px)) {
|
||||
width: 100%;
|
||||
}
|
||||
&__slides {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
&__item {
|
||||
&__slide {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
flex: 0 0 100%;
|
||||
padding: 15px;
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
@@ -8966,26 +8974,25 @@ noscript {
|
||||
font-weight: 400;
|
||||
max-height: 50vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__range {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
padding-inline-end: 18px;
|
||||
}
|
||||
&__range {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 10px;
|
||||
padding-inline-end: 18px;
|
||||
}
|
||||
|
||||
&__unread {
|
||||
position: absolute;
|
||||
top: 19px;
|
||||
inset-inline-end: 19px;
|
||||
display: block;
|
||||
background: $highlight-text-color;
|
||||
border-radius: 50%;
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
}
|
||||
&__unread {
|
||||
position: absolute;
|
||||
top: 19px;
|
||||
inset-inline-end: 19px;
|
||||
display: block;
|
||||
background: $highlight-text-color;
|
||||
border-radius: 50%;
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
@@ -8996,6 +9003,7 @@ noscript {
|
||||
inset-inline-end: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11587,4 +11595,10 @@ noscript {
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
6
app/javascript/types/dom.d.ts
vendored
Normal file
6
app/javascript/types/dom.d.ts
vendored
Normal file
@@ -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?: '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user