From 118c30fbc71bcccf1e0e99a7512c8af7d612b930 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Fri, 22 Aug 2025 11:57:39 +0200 Subject: [PATCH] Improvements for keyboard navigation in feeds (#35853) --- .../mastodon/components/hotkeys/index.tsx | 11 +- app/javascript/mastodon/components/status.jsx | 12 -- .../mastodon/components/status_list.jsx | 84 +---------- .../components/conversation.jsx | 14 +- .../components/conversations_list.jsx | 26 ---- .../components/inline_follow_suggestions.tsx | 12 +- .../features/keyboard_shortcuts/index.jsx | 8 +- .../notifications/components/notification.jsx | 16 --- .../features/notifications/request.jsx | 27 ---- .../components/notification_group.tsx | 14 +- .../features/notifications_v2/index.tsx | 57 +------- .../mastodon/features/status/index.jsx | 63 +-------- app/javascript/mastodon/features/ui/index.jsx | 42 ++++-- .../mastodon/features/ui/util/focusUtils.ts | 132 ++++++++++++++++++ app/javascript/mastodon/locales/en.json | 1 + .../styles/mastodon-light/diff.scss | 4 + .../styles/mastodon/components.scss | 4 + 17 files changed, 196 insertions(+), 331 deletions(-) create mode 100644 app/javascript/mastodon/features/ui/util/focusUtils.ts diff --git a/app/javascript/mastodon/components/hotkeys/index.tsx b/app/javascript/mastodon/components/hotkeys/index.tsx index 7e840ab9558..33d11dab922 100644 --- a/app/javascript/mastodon/components/hotkeys/index.tsx +++ b/app/javascript/mastodon/components/hotkeys/index.tsx @@ -7,11 +7,7 @@ import { normalizeKey, isKeyboardEvent } from './utils'; * the hotkey with a higher priority is selected. All others * are ignored. */ -const hotkeyPriority = { - singleKey: 0, - combo: 1, - sequence: 2, -} as const; +const hotkeyPriority = { singleKey: 0, combo: 1, sequence: 2 } as const; /** * This type of function receives a keyboard event and an array of @@ -105,14 +101,15 @@ const hotkeyMatcherMap = { new: just('n'), forceNew: optionPlus('n'), focusColumn: any('1', '2', '3', '4', '5', '6', '7', '8', '9'), + focusLoadMore: just('l'), reply: just('r'), favourite: just('f'), boost: just('b'), mention: just('m'), open: any('enter', 'o'), openProfile: just('p'), - moveDown: any('down', 'j'), - moveUp: any('up', 'k'), + moveDown: just('j'), + moveUp: just('k'), toggleHidden: just('x'), toggleSensitive: just('h'), toggleComposeSpoilers: optionPlus('x'), diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 7271e1d6266..98b9ce5a823 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -114,8 +114,6 @@ class Status extends ImmutablePureComponent { muted: PropTypes.bool, hidden: PropTypes.bool, unread: PropTypes.bool, - onMoveUp: PropTypes.func, - onMoveDown: PropTypes.func, showThread: PropTypes.bool, isQuotedPost: PropTypes.bool, getScrollPosition: PropTypes.func, @@ -328,14 +326,6 @@ class Status extends ImmutablePureComponent { history.push(`/@${status.getIn(['account', 'acct'])}`); }; - handleHotkeyMoveUp = e => { - this.props.onMoveUp?.(this.props.status.get('id'), this.node.getAttribute('data-featured')); - }; - - handleHotkeyMoveDown = e => { - this.props.onMoveDown?.(this.props.status.get('id'), this.node.getAttribute('data-featured')); - }; - handleHotkeyToggleHidden = () => { const { onToggleHidden } = this.props; const status = this._properStatus(); @@ -399,8 +389,6 @@ class Status extends ImmutablePureComponent { mention: this.handleHotkeyMention, open: this.handleHotkeyOpen, openProfile: this.handleHotkeyOpenProfile, - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, toggleHidden: this.handleHotkeyToggleHidden, toggleSensitive: this.handleHotkeyToggleSensitive, openMedia: this.handleHotkeyOpenMedia, diff --git a/app/javascript/mastodon/components/status_list.jsx b/app/javascript/mastodon/components/status_list.jsx index c3055aeeab5..cb2a7464cb0 100644 --- a/app/javascript/mastodon/components/status_list.jsx +++ b/app/javascript/mastodon/components/status_list.jsx @@ -14,6 +14,7 @@ import { StatusQuoteManager } from '../components/status_quoted'; import { LoadGap } from './load_gap'; import ScrollableList from './scrollable_list'; + export default class StatusList extends ImmutablePureComponent { static propTypes = { @@ -40,84 +41,6 @@ export default class StatusList extends ImmutablePureComponent { trackScroll: true, }; - componentDidMount() { - this.columnHeaderHeight = this.node?.node - ? parseFloat( - getComputedStyle(this.node.node).getPropertyValue('--column-header-height') - ) || 0 - : 0; - } - - getFeaturedStatusCount = () => { - return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0; - }; - - getCurrentStatusIndex = (id, featured) => { - if (featured) { - return this.props.featuredStatusIds.indexOf(id); - } else { - return this.props.statusIds.indexOf(id) + this.getFeaturedStatusCount(); - } - }; - - handleMoveUp = (id, featured) => { - const index = this.getCurrentStatusIndex(id, featured); - this._selectChild(id, index, -1); - }; - - handleMoveDown = (id, featured) => { - const index = this.getCurrentStatusIndex(id, featured); - this._selectChild(id, index, 1); - }; - - _selectChild = (id, index, direction) => { - const listContainer = this.node?.node; - let listItem = listContainer?.querySelector( - // :nth-child uses 1-based indexing - `.item-list > :nth-child(${index + 1 + direction})` - ); - - if (!listItem) { - return; - } - - // If selected container element is empty, we skip it - if (listItem.matches(':empty')) { - this._selectChild(id, index + direction, direction); - return; - } - - // Check if the list item is a post - let targetElement = listItem.querySelector('.focusable'); - - // Otherwise, check if the item contains follow suggestions or - // is a 'load more' button. - if ( - !targetElement && ( - listItem.querySelector('.inline-follow-suggestions') || - listItem.matches('.load-more') - ) - ) { - targetElement = listItem; - } - - if (targetElement) { - const elementRect = targetElement.getBoundingClientRect(); - - const isFullyVisible = - elementRect.top >= this.columnHeaderHeight && - elementRect.bottom <= window.innerHeight; - - if (!isFullyVisible) { - targetElement.scrollIntoView({ - block: direction === 1 ? 'start' : 'center', - }); - } - - targetElement.focus(); - } - } - handleLoadOlder = debounce(() => { const { statusIds, lastId, onLoadMore } = this.props; onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined)); @@ -158,8 +81,6 @@ export default class StatusList extends ImmutablePureComponent { ); } - } diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx index ec3621f0c06..f701ab0f045 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx @@ -45,7 +45,7 @@ const getAccounts = createSelector( const getStatus = makeGetStatus(); -export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => { +export const Conversation = ({ conversation, scrollKey }) => { const id = conversation.get('id'); const unread = conversation.get('unread'); const lastStatusId = conversation.get('last_status'); @@ -110,14 +110,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) dispatch(deleteConversation(id)); }, [dispatch, id]); - const handleHotkeyMoveUp = useCallback(() => { - onMoveUp(id); - }, [id, onMoveUp]); - - const handleHotkeyMoveDown = useCallback(() => { - onMoveDown(id); - }, [id, onMoveDown]); - const handleConversationMute = useCallback(() => { if (lastStatus.get('muted')) { dispatch(unmuteStatus(lastStatus.get('id'))); @@ -161,8 +153,6 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) const handlers = { reply: handleReply, open: handleClick, - moveUp: handleHotkeyMoveUp, - moveDown: handleHotkeyMoveDown, toggleHidden: handleShowMore, }; @@ -224,6 +214,4 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) Conversation.propTypes = { conversation: ImmutablePropTypes.map.isRequired, scrollKey: PropTypes.string, - onMoveUp: PropTypes.func, - onMoveDown: PropTypes.func, }; diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx index c9fc098a527..dac68864d3d 100644 --- a/app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx +++ b/app/javascript/mastodon/features/direct_timeline/components/conversations_list.jsx @@ -10,20 +10,6 @@ import ScrollableList from 'mastodon/components/scrollable_list'; import { Conversation } from './conversation'; -const focusChild = (node, index, alignTop) => { - const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); - - if (element) { - if (alignTop && node.scrollTop > element.offsetTop) { - element.scrollIntoView(true); - } else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) { - element.scrollIntoView(false); - } - - element.focus(); - } -}; - export const ConversationsList = ({ scrollKey, ...other }) => { const listRef = useRef(); const conversations = useSelector(state => state.getIn(['conversations', 'items'])); @@ -32,16 +18,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => { const dispatch = useDispatch(); const lastStatusId = conversations.last()?.get('last_status'); - const handleMoveUp = useCallback(id => { - const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1; - focusChild(listRef.current.node, elementIndex, true); - }, [listRef, conversations]); - - const handleMoveDown = useCallback(id => { - const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1; - focusChild(listRef.current.node, elementIndex, false); - }, [listRef, conversations]); - const debouncedLoadMore = useMemo(() => debounce(id => { dispatch(expandConversations({ maxId: id })); }, 300, { leading: true }), [dispatch]); @@ -58,8 +34,6 @@ export const ConversationsList = ({ scrollKey, ...other }) => { ))} diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx index 577ca9b8395..3df6d67ecf6 100644 --- a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx +++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback, useRef, useState } from 'react'; +import { useEffect, useCallback, useRef, useState, useId } from 'react'; import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; @@ -171,6 +171,7 @@ export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({ hidden, }) => { const intl = useIntl(); + const uniqueId = useId(); const dispatch = useAppDispatch(); const suggestions = useAppSelector((state) => state.suggestions.items); const isLoading = useAppSelector((state) => state.suggestions.isLoading); @@ -256,9 +257,14 @@ export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({ } return ( -
+
-

+

- up, k + k - down, j + j + + l + + 1-9 diff --git a/app/javascript/mastodon/features/notifications/components/notification.jsx b/app/javascript/mastodon/features/notifications/components/notification.jsx index ced09881a43..501ef8f5c14 100644 --- a/app/javascript/mastodon/features/notifications/components/notification.jsx +++ b/app/javascript/mastodon/features/notifications/components/notification.jsx @@ -57,8 +57,6 @@ class Notification extends ImmutablePureComponent { static propTypes = { notification: ImmutablePropTypes.map.isRequired, hidden: PropTypes.bool, - onMoveUp: PropTypes.func.isRequired, - onMoveDown: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onFavourite: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired, @@ -73,16 +71,6 @@ class Notification extends ImmutablePureComponent { ...WithRouterPropTypes, }; - handleMoveUp = () => { - const { notification, onMoveUp } = this.props; - onMoveUp(notification.get('id')); - }; - - handleMoveDown = () => { - const { notification, onMoveDown } = this.props; - onMoveDown(notification.get('id')); - }; - handleOpen = () => { const { notification } = this.props; @@ -128,8 +116,6 @@ class Notification extends ImmutablePureComponent { mention: this.handleMention, open: this.handleOpen, openProfile: this.handleOpenProfile, - moveUp: this.handleMoveUp, - moveDown: this.handleMoveDown, toggleHidden: this.handleHotkeyToggleHidden, }; } @@ -180,8 +166,6 @@ class Notification extends ImmutablePureComponent { id={notification.get('status')} withDismiss hidden={this.props.hidden} - onMoveDown={this.handleMoveDown} - onMoveUp={this.handleMoveUp} contextType='notifications' getScrollPosition={this.props.getScrollPosition} updateScrollBottom={this.props.updateScrollBottom} diff --git a/app/javascript/mastodon/features/notifications/request.jsx b/app/javascript/mastodon/features/notifications/request.jsx index a7e5180a4f7..bf0ccc54092 100644 --- a/app/javascript/mastodon/features/notifications/request.jsx +++ b/app/javascript/mastodon/features/notifications/request.jsx @@ -31,21 +31,6 @@ const messages = defineMessages({ dismiss: { id: 'notification_requests.dismiss', defaultMessage: 'Dismiss' }, }); -const selectChild = (ref, index, alignTop) => { - const container = ref.current.node; - const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); - - if (element) { - if (alignTop && container.scrollTop > element.offsetTop) { - element.scrollIntoView(true); - } else if (!alignTop && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { - element.scrollIntoView(false); - } - - element.focus(); - } -}; - export const NotificationRequest = ({ multiColumn, params: { id } }) => { const columnRef = useRef(); const intl = useIntl(); @@ -74,16 +59,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => { dispatch(acceptNotificationRequest({ id })); }, [dispatch, id]); - const handleMoveUp = useCallback(id => { - const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) - 1; - selectChild(columnRef, elementIndex, true); - }, [columnRef, notifications]); - - const handleMoveDown = useCallback(id => { - const elementIndex = notifications.findIndex(item => item !== null && item.get('id') === id) + 1; - selectChild(columnRef, elementIndex, false); - }, [columnRef, notifications]); - useEffect(() => { dispatch(fetchNotificationRequest({ id })); }, [dispatch, id]); @@ -146,8 +121,6 @@ export const NotificationRequest = ({ multiColumn, params: { id } }) => { key={item.get('id')} notification={item} accountId={item.get('account')} - onMoveUp={handleMoveUp} - onMoveDown={handleMoveDown} /> ))} diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx index eba39e17b7a..e992335cbc4 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_group.tsx @@ -24,9 +24,7 @@ import { NotificationUpdate } from './notification_update'; export const NotificationGroup: React.FC<{ notificationGroupId: NotificationGroupModel['group_key']; unread: boolean; - onMoveUp: (groupId: string) => void; - onMoveDown: (groupId: string) => void; -}> = ({ notificationGroupId, unread, onMoveUp, onMoveDown }) => { +}> = ({ notificationGroupId, unread }) => { const notificationGroup = useAppSelector((state) => state.notificationGroups.groups.find( (item) => item.type !== 'gap' && item.group_key === notificationGroupId, @@ -42,14 +40,6 @@ export const NotificationGroup: React.FC<{ const handlers = useMemo( () => ({ - moveUp: () => { - onMoveUp(notificationGroupId); - }, - - moveDown: () => { - onMoveDown(notificationGroupId); - }, - openProfile: () => { if (accountId) dispatch(navigateToProfile(accountId)); }, @@ -58,7 +48,7 @@ export const NotificationGroup: React.FC<{ if (accountId) dispatch(mentionComposeById(accountId)); }, }), - [dispatch, notificationGroupId, accountId, onMoveUp, onMoveDown], + [dispatch, accountId], ); if (!notificationGroup || notificationGroup.type === 'gap') return null; diff --git a/app/javascript/mastodon/features/notifications_v2/index.tsx b/app/javascript/mastodon/features/notifications_v2/index.tsx index bb476fe51fc..7c73d602fc7 100644 --- a/app/javascript/mastodon/features/notifications_v2/index.tsx +++ b/app/javascript/mastodon/features/notifications_v2/index.tsx @@ -99,29 +99,6 @@ export const Notifications: React.FC<{ const columnRef = useRef(null); - const selectChild = useCallback((index: number, alignTop: boolean) => { - const container = columnRef.current?.node as HTMLElement | undefined; - - if (!container) return; - - const element = container.querySelector( - `article:nth-of-type(${index + 1}) .focusable`, - ); - - if (element) { - if (alignTop && container.scrollTop > element.offsetTop) { - element.scrollIntoView(true); - } else if ( - !alignTop && - container.scrollTop + container.clientHeight < - element.offsetTop + element.offsetHeight - ) { - element.scrollIntoView(false); - } - element.focus(); - } - }, []); - // Keep track of mounted components for unread notification handling useEffect(() => { void dispatch(mountNotifications()); @@ -187,28 +164,6 @@ export const Notifications: React.FC<{ columnRef.current?.scrollTop(); }, []); - const handleMoveUp = useCallback( - (id: string) => { - const elementIndex = - notifications.findIndex( - (item) => item.type !== 'gap' && item.group_key === id, - ) - 1; - selectChild(elementIndex, true); - }, - [notifications, selectChild], - ); - - const handleMoveDown = useCallback( - (id: string) => { - const elementIndex = - notifications.findIndex( - (item) => item.type !== 'gap' && item.group_key === id, - ) + 1; - selectChild(elementIndex, false); - }, - [notifications, selectChild], - ); - const handleMarkAsRead = useCallback(() => { dispatch(markNotificationsAsRead()); void dispatch(submitMarkers({ immediate: true })); @@ -241,8 +196,6 @@ export const Notifications: React.FC<{ ), ); - }, [ - notifications, - isLoading, - hasMore, - lastReadId, - handleLoadGap, - handleMoveUp, - handleMoveDown, - ]); + }, [notifications, isLoading, hasMore, lastReadId, handleLoadGap]); const prepend = ( <> diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index 36a67226f86..d61b6fb5c2d 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -396,15 +396,6 @@ class Status extends ImmutablePureComponent { this.props.dispatch(unblockDomain(domain)); }; - - handleHotkeyMoveUp = () => { - this.handleMoveUp(this.props.status.get('id')); - }; - - handleHotkeyMoveDown = () => { - this.handleMoveDown(this.props.status.get('id')); - }; - handleHotkeyReply = e => { e.preventDefault(); this.handleReplyClick(this.props.status); @@ -439,54 +430,6 @@ class Status extends ImmutablePureComponent { this.handleTranslate(this.props.status); }; - handleMoveUp = id => { - const { status, ancestorsIds, descendantsIds } = this.props; - - if (id === status.get('id')) { - this._selectChild(ancestorsIds.length - 1, true); - } else { - let index = ancestorsIds.indexOf(id); - - if (index === -1) { - index = descendantsIds.indexOf(id); - this._selectChild(ancestorsIds.length + index, true); - } else { - this._selectChild(index - 1, true); - } - } - }; - - handleMoveDown = id => { - const { status, ancestorsIds, descendantsIds } = this.props; - - if (id === status.get('id')) { - this._selectChild(ancestorsIds.length + 1, false); - } else { - let index = ancestorsIds.indexOf(id); - - if (index === -1) { - index = descendantsIds.indexOf(id); - this._selectChild(ancestorsIds.length + index + 2, false); - } else { - this._selectChild(index + 1, false); - } - } - }; - - _selectChild (index, align_top) { - const container = this.node; - const element = container.querySelectorAll('.focusable')[index]; - - if (element) { - if (align_top && container.scrollTop > element.offsetTop) { - element.scrollIntoView(true); - } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { - element.scrollIntoView(false); - } - element.focus(); - } - } - renderChildren (list, ancestors) { const { params: { statusId } } = this.props; @@ -494,8 +437,6 @@ class Status extends ImmutablePureComponent { 0 ? list[i - 1] : undefined} nextId={list[i + 1] || (ancestors && statusId)} @@ -602,8 +543,6 @@ class Status extends ImmutablePureComponent { } const handlers = { - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, reply: this.handleHotkeyReply, favourite: this.handleHotkeyFavourite, boost: this.handleHotkeyBoost, @@ -626,7 +565,7 @@ class Status extends ImmutablePureComponent { /> -
+
{ancestors} diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index e8eef704efa..f06cf6d36e5 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -77,6 +77,7 @@ import { AccountFeatured, } from './util/async-components'; import { ColumnsContextProvider } from './util/columns_context'; +import { focusColumn, getFocusedItemIndex, focusItemSibling } from './util/focusUtils'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; // Dummy import, to make sure that ends up in the application bundle. @@ -446,20 +447,34 @@ class UI extends PureComponent { }; handleHotkeyFocusColumn = e => { - const index = (e.key * 1) + 1; // First child is drawer, skip that - const column = this.node.querySelector(`.column:nth-child(${index})`); - if (!column) return; - const container = column.querySelector('.scrollable'); + focusColumn({index: e.key * 1}); + }; - if (container) { - const status = container.querySelector('.focusable'); + handleHotkeyLoadMore = () => { + document.querySelector('.load-more')?.focus(); + }; - if (status) { - if (container.scrollTop > status.offsetTop) { - status.scrollIntoView(true); - } - status.focus(); - } + handleMoveUp = () => { + const currentItemIndex = getFocusedItemIndex(); + if (currentItemIndex === -1) { + focusColumn({ + index: 1, + focusItem: 'first-visible', + }); + } else { + focusItemSibling(currentItemIndex, -1); + } + }; + + handleMoveDown = () => { + const currentItemIndex = getFocusedItemIndex(); + if (currentItemIndex === -1) { + focusColumn({ + index: 1, + focusItem: 'first-visible', + }); + } else { + focusItemSibling(currentItemIndex, 1); } }; @@ -542,6 +557,9 @@ class UI extends PureComponent { forceNew: this.handleHotkeyForceNew, toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers, focusColumn: this.handleHotkeyFocusColumn, + focusLoadMore: this.handleHotkeyLoadMore, + moveDown: this.handleMoveDown, + moveUp: this.handleMoveUp, back: this.handleHotkeyBack, goToHome: this.handleHotkeyGoToHome, goToNotifications: this.handleHotkeyGoToNotifications, diff --git a/app/javascript/mastodon/features/ui/util/focusUtils.ts b/app/javascript/mastodon/features/ui/util/focusUtils.ts new file mode 100644 index 00000000000..a19852e0d26 --- /dev/null +++ b/app/javascript/mastodon/features/ui/util/focusUtils.ts @@ -0,0 +1,132 @@ +import initialState from '@/mastodon/initial_state'; + +interface FocusColumnOptions { + index?: number; + focusItem?: 'first' | 'first-visible'; +} + +/** + * Move focus to the column of the passed index (1-based). + * Can either focus the topmost item or the first one in the viewport + */ +export function focusColumn({ + index = 1, + focusItem = 'first', +}: FocusColumnOptions = {}) { + // Skip the leftmost drawer in multi-column mode + const indexOffset = initialState?.meta.advanced_layout ? 1 : 0; + + const column = document.querySelector( + `.column:nth-child(${index + indexOffset})`, + ); + + if (!column) return; + + const container = column.querySelector('.scrollable'); + + if (!container) return; + + let itemToFocus: HTMLElement | null = null; + + if (focusItem === 'first-visible') { + const focusableItems = Array.from( + container.querySelectorAll( + '.focusable:not(.status__quote .focusable)', + ), + ); + + const viewportHeight = + window.innerHeight || document.documentElement.clientHeight; + + // Find first item visible in the viewport + itemToFocus = + focusableItems.find((item) => { + const { top } = item.getBoundingClientRect(); + return top >= 0 && top < viewportHeight; + }) ?? null; + } else { + itemToFocus = container.querySelector('.focusable'); + } + + if (itemToFocus) { + if (container.scrollTop > itemToFocus.offsetTop) { + itemToFocus.scrollIntoView(true); + } + itemToFocus.focus(); + } +} + +/** + * Get the index of the currently focused item in one of our item lists + */ +export function getFocusedItemIndex() { + const focusedElement = document.activeElement; + const itemList = focusedElement?.closest('.item-list'); + + if (!focusedElement || !itemList) { + return -1; + } + + let focusedItem: HTMLElement | null = null; + if (focusedElement.parentElement === itemList) { + focusedItem = focusedElement as HTMLElement; + } else { + focusedItem = focusedElement.closest('.item-list > *'); + } + + if (!focusedItem) return -1; + + const items = Array.from(itemList.children); + return items.indexOf(focusedItem); +} + +/** + * Focus the item next to the one with the provided index + */ +export function focusItemSibling( + index: number, + direction: 1 | -1, + scrollThreshold = 62, +) { + const focusedElement = document.activeElement; + const itemList = focusedElement?.closest('.item-list'); + + const siblingItem = itemList?.querySelector( + // :nth-child uses 1-based indexing + `.item-list > :nth-child(${index + 1 + direction})`, + ); + + if (!siblingItem) { + return; + } + + // If sibling element is empty, we skip it + if (siblingItem.matches(':empty')) { + focusItemSibling(index + direction, direction); + return; + } + + // Check if the sibling is a post or a 'follow suggestions' widget + let targetElement = siblingItem.querySelector('.focusable'); + + // Otherwise, check if the item is a 'load more' button. + if (!targetElement && siblingItem.matches('.load-more')) { + targetElement = siblingItem; + } + + if (targetElement) { + const elementRect = targetElement.getBoundingClientRect(); + + const isFullyVisible = + elementRect.top >= scrollThreshold && + elementRect.bottom <= window.innerHeight; + + if (!isFullyVisible) { + targetElement.scrollIntoView({ + block: direction === 1 ? 'start' : 'center', + }); + } + + targetElement.focus(); + } +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 01eb56c72c9..c025d8b69b6 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -483,6 +483,7 @@ "keyboard_shortcuts.home": "Open home timeline", "keyboard_shortcuts.hotkey": "Hotkey", "keyboard_shortcuts.legend": "Display this legend", + "keyboard_shortcuts.load_more": "Focus \"Load more\" button", "keyboard_shortcuts.local": "Open local timeline", "keyboard_shortcuts.mention": "Mention author", "keyboard_shortcuts.muted": "Open muted users list", diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index d92012a1573..96d6f81bcfc 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -435,6 +435,10 @@ .inline-follow-suggestions { background-color: color.change($ui-highlight-color, $alpha: 0.1); border-bottom-color: color.change($ui-highlight-color, $alpha: 0.3); + + &.focusable:focus-visible { + background: color.change($ui-highlight-color, $alpha: 0.1); + } } .inline-follow-suggestions__body__scrollable__card { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 08e1136cd9b..2e8877f3099 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -10376,6 +10376,10 @@ noscript { border-bottom: 1px solid var(--background-border-color); background: color.change($ui-highlight-color, $alpha: 0.05); + &.focusable:focus-visible { + background: color.change($ui-highlight-color, $alpha: 0.05); + } + &__header { display: flex; align-items: center;