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')) {