diff --git a/app/javascript/mastodon/components/scrollable_list/components.tsx b/app/javascript/mastodon/components/scrollable_list/components.tsx new file mode 100644 index 0000000000..40dd3cc587 --- /dev/null +++ b/app/javascript/mastodon/components/scrollable_list/components.tsx @@ -0,0 +1,85 @@ +import type { ComponentPropsWithoutRef } from 'react'; +import { Children, forwardRef } from 'react'; + +import classNames from 'classnames'; + +import { LoadingIndicator } from '../loading_indicator'; + +export const Scrollable = forwardRef< + HTMLDivElement, + ComponentPropsWithoutRef<'div'> & { + flex?: boolean; + fullscreen?: boolean; + } +>(({ flex = true, fullscreen, className, children, ...otherProps }, ref) => { + return ( +
+ {children} +
+ ); +}); + +Scrollable.displayName = 'Scrollable'; + +export const ItemList = forwardRef< + HTMLDivElement, + ComponentPropsWithoutRef<'div'> & { + isLoading?: boolean; + emptyMessage?: React.ReactNode; + } +>(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => { + if (Children.count(children) === 0 && emptyMessage) { + return
{emptyMessage}
; + } + + return ( + <> +
+ {!isLoading && children} +
+ {isLoading && ( +
+ +
+ )} + + ); +}); + +ItemList.displayName = 'ItemList'; + +export const Article = forwardRef< + HTMLElement, + ComponentPropsWithoutRef<'article'> & { + focusable?: boolean; + 'data-id'?: string; + 'aria-posinset': number; + 'aria-setsize': number; + } +>(({ focusable, className, children, ...otherProps }, ref) => { + return ( +
+ {children} +
+ ); +}); + +Article.displayName = 'Article'; diff --git a/app/javascript/mastodon/components/scrollable_list.jsx b/app/javascript/mastodon/components/scrollable_list/index.jsx similarity index 91% rename from app/javascript/mastodon/components/scrollable_list.jsx rename to app/javascript/mastodon/components/scrollable_list/index.jsx index 38c3cd991b..02cbb056f7 100644 --- a/app/javascript/mastodon/components/scrollable_list.jsx +++ b/app/javascript/mastodon/components/scrollable_list/index.jsx @@ -1,7 +1,6 @@ import PropTypes from 'prop-types'; import { Children, cloneElement, PureComponent } from 'react'; -import classNames from 'classnames'; import { useLocation } from 'react-router-dom'; import { List as ImmutableList } from 'immutable'; @@ -12,13 +11,14 @@ import { throttle } from 'lodash'; import { ScrollContainer } from 'mastodon/containers/scroll_container'; -import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; -import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; -import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; +import IntersectionObserverArticleContainer from '../../containers/intersection_observer_article_container'; +import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../../features/ui/util/fullscreen'; +import IntersectionObserverWrapper from '../../features/ui/util/intersection_observer_wrapper'; -import { LoadMore } from './load_more'; -import { LoadPending } from './load_pending'; -import { LoadingIndicator } from './loading_indicator'; +import { LoadMore } from '../load_more'; +import { LoadPending } from '../load_pending'; +import { LoadingIndicator } from '../loading_indicator'; +import { Scrollable, ItemList } from './components'; const MOUSE_IDLE_DELAY = 300; @@ -336,24 +336,20 @@ class ScrollableList extends PureComponent { if (showLoading) { scrollableArea = ( -
+ {prepend} -
- -
- -
+ {footer} -
+
); } else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) { scrollableArea = ( -
+ {prepend} -
+ {loadPending} {Children.map(this.props.children, (child, index) => ( @@ -378,14 +374,14 @@ class ScrollableList extends PureComponent { {loadMore} {!hasMore && append} -
+ {footer} -
+ ); } else { scrollableArea = ( -
+ {alwaysPrepend && prepend}
@@ -393,7 +389,7 @@ class ScrollableList extends PureComponent {
{footer} -
+ ); } diff --git a/app/javascript/mastodon/components/intersection_observer_article.jsx b/app/javascript/mastodon/components/scrollable_list/intersection_observer_article.jsx similarity index 91% rename from app/javascript/mastodon/components/intersection_observer_article.jsx rename to app/javascript/mastodon/components/scrollable_list/intersection_observer_article.jsx index 8efa969f9b..70337266e2 100644 --- a/app/javascript/mastodon/components/intersection_observer_article.jsx +++ b/app/javascript/mastodon/components/scrollable_list/intersection_observer_article.jsx @@ -1,8 +1,9 @@ import PropTypes from 'prop-types'; import { cloneElement, Component } from 'react'; -import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; -import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; +import getRectFromEntry from '../../features/ui/util/get_rect_from_entry'; +import scheduleIdleTask from '../../features/ui/util/schedule_idle_task'; +import { Article } from './components'; // Diff these props in the "unrendered" state const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; @@ -108,23 +109,22 @@ export default class IntersectionObserverArticle extends Component { if (!isIntersecting && (isHidden || cachedHeight)) { return ( -
{children && cloneElement(children, { hidden: true })} -
+ ); } return ( -
+
{children && cloneElement(children, { hidden: false })} -
+
); } diff --git a/app/javascript/mastodon/containers/intersection_observer_article_container.js b/app/javascript/mastodon/containers/intersection_observer_article_container.js index 8d9bda6704..4d3e270202 100644 --- a/app/javascript/mastodon/containers/intersection_observer_article_container.js +++ b/app/javascript/mastodon/containers/intersection_observer_article_container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { setHeight } from '../actions/height_cache'; -import IntersectionObserverArticle from '../components/intersection_observer_article'; +import IntersectionObserverArticle from '../components/scrollable_list/intersection_observer_article'; const makeMapStateToProps = (state, props) => ({ cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]), diff --git a/app/javascript/mastodon/features/account_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index db01c5b272..ddb415397f 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/features/account_featured/index.tsx @@ -12,6 +12,11 @@ import { Account } from 'mastodon/components/account'; import { ColumnBackButton } from 'mastodon/components/column_back_button'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { RemoteHint } from 'mastodon/components/remote_hint'; +import { + Article, + ItemList, + Scrollable, +} from 'mastodon/components/scrollable_list/components'; import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header'; import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error'; import Column from 'mastodon/features/ui/components/column'; @@ -115,7 +120,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ -
+ {accountId && ( )} @@ -127,15 +132,17 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ defaultMessage='Collections' /> -
+ {publicCollections.map((item, index) => ( ))} -
+ )} {!featuredTags.isEmpty() && ( @@ -146,9 +153,18 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ defaultMessage='Hashtags' /> - {featuredTags.map((tag) => ( - - ))} + + {featuredTags.map((tag, index) => ( +
+ +
+ ))} +
)} {!featuredAccountIds.isEmpty() && ( @@ -159,13 +175,22 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ defaultMessage='Profiles' /> - {featuredAccountIds.map((featuredAccountId) => ( - - ))} + + {featuredAccountIds.map((featuredAccountId, index) => ( +
+ +
+ ))} +
)} -
+
); }; diff --git a/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx index 1a7e18b521..34516c0634 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx +++ b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx @@ -7,6 +7,7 @@ import { Link } from 'react-router-dom'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; +import { Article } from 'mastodon/components/scrollable_list/components'; import classes from './collection_list_item.module.scss'; import { CollectionMenu } from './collection_menu'; @@ -68,19 +69,22 @@ export const CollectionMetaData: React.FC<{ export const CollectionListItem: React.FC<{ collection: ApiCollectionJSON; withoutBorder?: boolean; -}> = ({ collection, withoutBorder }) => { + positionInList: number; + listSize: number; +}> = ({ collection, withoutBorder, positionInList, listSize }) => { const { id, name } = collection; const linkId = useId(); return ( -

@@ -92,6 +96,6 @@ export const CollectionListItem: React.FC<{

-
+ ); }; diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index 0fc13c8f79..e0d2ff12d7 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -19,7 +19,11 @@ import { LinkedDisplayName, } from 'mastodon/components/display_name'; import { IconButton } from 'mastodon/components/icon_button'; -import ScrollableList from 'mastodon/components/scrollable_list'; +import { + Article, + ItemList, + Scrollable, +} from 'mastodon/components/scrollable_list/components'; import { Tag } from 'mastodon/components/tags/tag'; import { useAccount } from 'mastodon/hooks/useAccount'; import { me } from 'mastodon/initial_state'; @@ -202,24 +206,27 @@ export const CollectionDetailPage: React.FC<{ multiColumn={multiColumn} /> - : null - } - > - {collection?.items.map(({ account_id }) => ( - - ))} - + + {collection && } + + {collection?.items.map(({ account_id }, index, items) => ( +
+ +
+ ))} +
+
{pageTitle} diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx index cdebd37cdf..be4426dfdd 100644 --- a/app/javascript/mastodon/features/collections/editor/accounts.tsx +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -21,7 +21,11 @@ import { EmptyState } from 'mastodon/components/empty_state'; import { FormStack, Combobox } from 'mastodon/components/form_fields'; import { Icon } from 'mastodon/components/icon'; import { IconButton } from 'mastodon/components/icon_button'; -import ScrollableList from 'mastodon/components/scrollable_list'; +import { + Article, + ItemList, + Scrollable, +} from 'mastodon/components/scrollable_list/components'; import { useSearchAccounts } from 'mastodon/features/lists/use_search_accounts'; import { useAccount } from 'mastodon/hooks/useAccount'; import { me } from 'mastodon/initial_state'; @@ -390,9 +394,8 @@ export const CollectionAccounts: React.FC<{ )} -
- + } - // TODO: Re-add `bindToDocument={!multiColumn}` > - {accountIds.map((accountId) => ( - ( +
+ aria-posinset={index} + aria-setsize={accountIds.length} + > + +
))} -
-
+ + {!isEditMode && (
diff --git a/app/javascript/mastodon/features/collections/editor/styles.module.scss b/app/javascript/mastodon/features/collections/editor/styles.module.scss index c7fbf67695..1991aa4211 100644 --- a/app/javascript/mastodon/features/collections/editor/styles.module.scss +++ b/app/javascript/mastodon/features/collections/editor/styles.module.scss @@ -49,12 +49,7 @@ flex-grow: 1; } -.scrollableWrapper { - display: flex; - flex: 1; - margin-inline: -8px; -} - +.scrollableWrapper, .scrollableInner { margin-inline: -8px; } diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx index 24819cf755..e560e01366 100644 --- a/app/javascript/mastodon/features/collections/index.tsx +++ b/app/javascript/mastodon/features/collections/index.tsx @@ -11,7 +11,10 @@ import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react'; import { Column } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; import { Icon } from 'mastodon/components/icon'; -import ScrollableList from 'mastodon/components/scrollable_list'; +import { + ItemList, + Scrollable, +} from 'mastodon/components/scrollable_list/components'; import { fetchAccountCollections, selectAccountCollections, @@ -85,16 +88,18 @@ export const Collections: React.FC<{ } /> - - {collections.map((item) => ( - - ))} - + + + {collections.map((item, index) => ( + + ))} + + {intl.formatMessage(messages.heading)} diff --git a/app/javascript/mastodon/features/ui/util/focusUtils.ts b/app/javascript/mastodon/features/ui/util/focusUtils.ts index 8237eb286b..c632a0a51d 100644 --- a/app/javascript/mastodon/features/ui/util/focusUtils.ts +++ b/app/javascript/mastodon/features/ui/util/focusUtils.ts @@ -169,7 +169,9 @@ export function focusItemSibling(index: number, direction: 1 | -1) { } // Check if the sibling is a post or a 'follow suggestions' widget - let targetElement = siblingItem.querySelector('.focusable'); + let targetElement = siblingItem.matches('.focusable') + ? siblingItem + : siblingItem.querySelector('.focusable'); // Otherwise, check if the item is a 'load more' button. if (!targetElement && siblingItem.matches('.load-more')) {