diff --git a/app/javascript/mastodon/components/carousel/carousel.stories.tsx b/app/javascript/mastodon/components/carousel/carousel.stories.tsx new file mode 100644 index 0000000000..5117bc08e3 --- /dev/null +++ b/app/javascript/mastodon/components/carousel/carousel.stories.tsx @@ -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 = ({ + active, + text, + color, +}) => ( +
+ {text} +
+); + +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, + 'items' | 'renderItem' | 'emptyFallback' | 'onChangeSlide' +>; + +const meta = { + title: 'Components/Carousel', + args: { + items: slides, + renderItem(item, active) { + return ; + }, + onChangeSlide: fn(), + emptyFallback: 'No slides available', + }, + render(args) { + return ( + <> + + + + ); + }, + argTypes: { + emptyFallback: { + type: 'string', + }, + }, + tags: ['test'], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +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: [], + }, +}; diff --git a/app/javascript/mastodon/components/carousel/index.tsx b/app/javascript/mastodon/components/carousel/index.tsx new file mode 100644 index 0000000000..f2b9e9823b --- /dev/null +++ b/app/javascript/mastodon/components/carousel/index.tsx @@ -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: 'Slide {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; + onChangeSlide?: (index: number, ref: Element) => void; + paginationComponent?: ComponentType | null; + paginationProps?: Partial; + messages?: Record; + 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 & ComponentPropsWithoutRef<'div'>) => { + // Handle slide change + const [slideIndex, setSlideIndex] = useState(0); + const wrapperRef = useRef(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( + 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 ( +
+
+ {children} + {Pagination && items.length > 1 && ( + + )} +
+ + + {items.map((itemsProps, index) => ( + + 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, + })} + /> + ))} + +
+ ); +}; + +type CarouselSlideWrapperProps = { + observer: ResizeObserver; + className: string; + active: boolean; + item: SlideProps; + index: number; + label: string; +} & Pick, 'renderItem'>; + +const CarouselSlideWrapper = ({ + observer, + className, + active, + renderItem, + item, + index, + label, +}: CarouselSlideWrapperProps) => { + const handleRef = useCallback( + (instance: HTMLDivElement | null) => { + if (instance) { + observer.observe(instance); + } + }, + [observer], + ); + + const children = useMemo( + () => renderItem(item, active, index), + [renderItem, item, active, index], + ); + + return ( +
+ {children} +
+ ); +}; diff --git a/app/javascript/mastodon/components/carousel/pagination.tsx b/app/javascript/mastodon/components/carousel/pagination.tsx new file mode 100644 index 0000000000..a2666f486f --- /dev/null +++ b/app/javascript/mastodon/components/carousel/pagination.tsx @@ -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; +} + +export const CarouselPagination: FC = ({ + onNext, + onPrev, + current, + max, + className = '', + messages, +}) => { + const intl = useIntl(); + return ( +
+ + + {intl.formatMessage(messages.current, { + current: current + 1, + max, + sr: (chunk) => {chunk}, + })} + + +
+ ); +}; diff --git a/app/javascript/mastodon/components/carousel/styles.scss b/app/javascript/mastodon/components/carousel/styles.scss new file mode 100644 index 0000000000..bcd0bc7d3a --- /dev/null +++ b/app/javascript/mastodon/components/carousel/styles.scss @@ -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; + } +} diff --git a/app/javascript/mastodon/components/featured_carousel.tsx b/app/javascript/mastodon/components/featured_carousel.tsx index df64c43b42..c35f2f37f0 100644 --- a/app/javascript/mastodon/components/featured_carousel.tsx +++ b/app/javascript/mastodon/components/featured_carousel.tsx @@ -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).getIn( + [`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], + ImmutableList(), + ) as ImmutableList, + ], + (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: 'Post {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).getIn( - [`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], - ImmutableList(), - ) as ImmutableList, + const pinnedStatuses = useAppSelector((state) => + pinnedStatusesSelector(state, accountId, tagged), ); - // Handle slide change - const [slideIndex, setSlideIndex] = useState(0); - const wrapperRef = useRef(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 }) => ( + + ), + [], ); - // Handle slide heights - const [currentSlideHeight, setCurrentSlideHeight] = useState( - wrapperRef.current?.scrollHeight ?? 0, - ); - const previousSlideHeight = usePrevious(currentSlideHeight); - const observerRef = useRef( - 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 ( -
-
-

- - -

- {pinnedStatuses.size > 1 && ( - <> - - - - {(text) => {text}} - - {slideIndex + 1} / {pinnedStatuses.size} - - - - )} -
- - {pinnedStatuses.map((statusId, index) => ( - - ))} - -
- ); -}; - -interface FeaturedCarouselItemProps { - statusId: string; - active: boolean; - observer: ResizeObserver; -} - -const FeaturedCarouselItem: React.FC< - FeaturedCarouselItemProps & AnimatedProps> -> = ({ statusId, active, observer, ...props }) => { - const handleRef = useCallback( - (instance: HTMLDivElement | null) => { - if (instance) { - observer.observe(instance); - } - }, - [observer], - ); - - return ( - - - +

+ + +

+
); }; diff --git a/app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx b/app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx index 8513e6169b..39d3c881b0 100644 --- a/app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx +++ b/app/javascript/mastodon/features/home_timeline/components/announcements/announcement.tsx @@ -15,24 +15,24 @@ export interface IAnnouncement extends ApiAnnouncementJSON { interface AnnouncementProps { announcement: IAnnouncement; - selected: boolean; + active?: boolean; } export const Announcement: FC = ({ 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 ( - - + + = ({ - {unread && } + {unread && } ); }; diff --git a/app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx b/app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx index 335e0f1a38..cb44e1d075 100644 --- a/app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx +++ b/app/javascript/mastodon/features/home_timeline/components/announcements/index.tsx @@ -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>>], (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) => ( + + ), + [], ); - 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 ( -
+
{ src={mascot ?? elephantUIPlane} /> -
- - - {announcements - .map((announcement, idx) => ( - - )) - .reverse()} - - - - {announcements.length > 1 && ( -
- - - {index + 1} / {announcements.length} - - -
- )} -
+ + +
); }; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 0481eb7c0c..13473656aa 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -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": "Slide {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": "Post {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.", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 0ef95b0146..bd09f3b8b0 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -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; + } } diff --git a/app/javascript/types/dom.d.ts b/app/javascript/types/dom.d.ts new file mode 100644 index 0000000000..fcb1b0346f --- /dev/null +++ b/app/javascript/types/dom.d.ts @@ -0,0 +1,6 @@ +declare namespace React { + interface HTMLAttributes extends AriaAttributes, DOMAttributes { + // Add inert attribute support, which is only in React 19.2. See: https://github.com/facebook/react/pull/24730 + inert?: ''; + } +}