From bcb8553e01270bdb56827a701c4359d39643aa2c Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 10 Jun 2026 14:34:48 +0200 Subject: [PATCH] [Accessibility] Manage focus on navigation (#39350) --- app/javascript/mastodon/actions/modal.ts | 1 + .../mastodon/components/column_header.tsx | 8 +- .../mastodon/components/modal_root.jsx | 12 +- .../navigation_focus_target/index.tsx | 145 ++++++++++++++++++ app/javascript/mastodon/components/router.tsx | 5 + app/javascript/mastodon/components/status.jsx | 5 +- .../mastodon/containers/mastodon.jsx | 5 +- .../mastodon/features/about/index.jsx | 7 +- .../account_edit/components/field_actions.tsx | 1 + .../mastodon/features/account_edit/index.tsx | 20 ++- .../account_timeline/modals/field_modal.tsx | 15 +- .../closed_registrations_modal/index.jsx | 9 +- .../collections/components/share_modal.tsx | 5 +- .../mastodon/features/collections/index.tsx | 5 +- .../features/interaction_modal/index.tsx | 5 +- .../components/embedded_status.tsx | 3 +- .../mastodon/features/status/index.jsx | 11 +- .../ui/components/__tests__/column-test.jsx | 15 +- .../confirmation_modal.tsx | 9 +- app/javascript/mastodon/features/ui/index.jsx | 2 +- app/javascript/mastodon/reducers/modal.ts | 8 +- .../styles/mastodon/components.scss | 6 + 22 files changed, 265 insertions(+), 37 deletions(-) create mode 100644 app/javascript/mastodon/components/navigation_focus_target/index.tsx diff --git a/app/javascript/mastodon/actions/modal.ts b/app/javascript/mastodon/actions/modal.ts index 49af176a11..0978c32658 100644 --- a/app/javascript/mastodon/actions/modal.ts +++ b/app/javascript/mastodon/actions/modal.ts @@ -10,6 +10,7 @@ interface OpenModalPayload { modalType: ModalType; modalProps: ModalProps; previousModalProps?: ModalProps; + ignoreFocus?: boolean; } export const openModal = createAction('MODAL_OPEN'); diff --git a/app/javascript/mastodon/components/column_header.tsx b/app/javascript/mastodon/components/column_header.tsx index 676ae01919..77cba09fbe 100644 --- a/app/javascript/mastodon/components/column_header.tsx +++ b/app/javascript/mastodon/components/column_header.tsx @@ -19,6 +19,7 @@ import { useIdentity } from 'mastodon/identity_context'; import { useColumnIndexContext } from '../features/ui/components/columns_area'; import { getColumnSkipLinkId } from '../features/ui/components/skip_links'; +import { NavigationFocusTarget } from './navigation_focus_target'; import { useAppHistory } from './router'; export const messages = defineMessages({ @@ -285,7 +286,10 @@ export const ColumnHeader: React.FC = ({
{backButton} {hasTitle && ( -

+ {onClick ? (

+ )}
diff --git a/app/javascript/mastodon/components/modal_root.jsx b/app/javascript/mastodon/components/modal_root.jsx index 61ff19256f..c2f51a5673 100644 --- a/app/javascript/mastodon/components/modal_root.jsx +++ b/app/javascript/mastodon/components/modal_root.jsx @@ -7,6 +7,7 @@ import { multiply } from 'color-blend'; import { createBrowserHistory } from 'history'; import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router'; +import { IGNORE_FOCUS_ON_OPEN } from '../reducers/modal'; class ModalRoot extends PureComponent { @@ -21,7 +22,10 @@ class ModalRoot extends PureComponent { b: PropTypes.number, }), ]), - ignoreFocus: PropTypes.bool, + ignoreFocus: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string, // 'on-open', see IGNORE_FOCUS_ON_OPEN + ]), ...WithOptionalRouterPropTypes, }; @@ -118,7 +122,11 @@ class ModalRoot extends PureComponent { _ensureHistoryBuffer () { const { pathname, search, hash, state } = this.history.location; if (!state || state.mastodonModalKey !== this._modalHistoryKey) { - this.history.push({ pathname, search, hash }, { ...state, mastodonModalKey: this._modalHistoryKey }); + this.history.push({ pathname, search, hash }, { + ...state, + focusTarget: this.props.ignoreFocus !== IGNORE_FOCUS_ON_OPEN, + mastodonModalKey: this._modalHistoryKey, + }); } } diff --git a/app/javascript/mastodon/components/navigation_focus_target/index.tsx b/app/javascript/mastodon/components/navigation_focus_target/index.tsx new file mode 100644 index 0000000000..f4b42926a8 --- /dev/null +++ b/app/javascript/mastodon/components/navigation_focus_target/index.tsx @@ -0,0 +1,145 @@ +import { + createContext, + useContext, + useRef, + useLayoutEffect, + useCallback, +} from 'react'; + +import { useLocation } from 'react-router-dom'; + +import { polymorphicForwardRef } from '@/types/polymorphic'; + +import type { MastodonLocation } from '../router'; + +export const FOCUS_TARGET = { + POST: 'detailed-status', +} as const; + +export type FocusTarget = + | boolean + | (typeof FOCUS_TARGET)[keyof typeof FOCUS_TARGET]; + +const FocusTargetContext = + createContext | null>(null); + +/** + * `FocusTargetProvider` keeps track of whether focus should be + * set after a navigation. By default, any navigation will set the + * current value of `focusTargetRef` to `true`, which will cause + * the `NavigationFocusTarget` component to focus itself when it mounts. + * + * To disable this behaviour for a navigation, the focus target can be + * set to `false` using location state, for example: + * ``` + * location.push(url, { focusTarget: false }); + * ``` + * + * If the target page contains multiple `NavigationFocusTarget` components + * (e.g. a main heading and a post that should be focused), give the more + * specific `NavigationFocusTarget` instance a name, and pass the same name + * via location state: + * ``` + * location.push(url, { focusTarget: 'detailed-status' }); + * ``` + */ + +export const FocusTargetProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const focusTargetRef = useRef(false); + const previousLocationRef = useRef< + | (Pick & { + focusTarget?: FocusTarget; + }) + | null + >(null); + + const { + pathname, + search, + state = {}, + } = useLocation<{ focusTarget?: FocusTarget } | undefined>(); + + const { focusTarget } = state; + + useLayoutEffect(() => { + // We never want to set focus on page load, so we keep + // track of whether a manual navigation has occurred by comparing + // our current with the previous location: + const previous = previousLocationRef.current; + + // Bail out on the first render, populate previousLocationRef + if (previous === null) { + previousLocationRef.current = { pathname, search, focusTarget }; + return; + } + + // Bail out if location hasn't changed + if ( + previous.pathname === pathname && + previous.search === search && + previous.focusTarget === focusTarget + ) { + return; + } + + // Location has changed: + // - Set focusTarget + // – Store current location as previous + // (We store `focusTarget` as `false` to allow overriding it.) + previousLocationRef.current = { pathname, search, focusTarget: false }; + focusTargetRef.current = focusTarget ?? true; + }, [pathname, search, focusTarget]); + + return ( + + {children} + + ); +}; + +export function useFocusOnNavigation(targetName?: string) { + const focusTargetRef = useContext(FocusTargetContext); + + if (focusTargetRef === null) { + throw Error( + 'useFocusTargetContext must be used inside of a FocusTargetProvider', + ); + } + + return useCallback( + (element: HTMLElement | null) => { + const focusTarget = focusTargetRef.current; + + // Bail out if focusTarget was set to `false` + if (!element || !focusTarget) { + return; + } + + if (focusTarget === true || focusTarget === targetName) { + setTimeout(() => { + element.focus({ preventScroll: true }); + }, 0); + } + }, + [focusTargetRef, targetName], + ); +} + +interface FocusTargetElementProps extends React.ComponentPropsWithoutRef<'h1'> { + focusTargetName?: string; +} + +export const NavigationFocusTarget = polymorphicForwardRef< + 'h1', + FocusTargetElementProps +>(({ as: Component = 'h1', focusTargetName, children, ...otherProps }) => { + const focusOnNavigation = useFocusOnNavigation(focusTargetName); + + return ( + + {children} + + ); +}); diff --git a/app/javascript/mastodon/components/router.tsx b/app/javascript/mastodon/components/router.tsx index bd6e4b568f..2a53d6dff4 100644 --- a/app/javascript/mastodon/components/router.tsx +++ b/app/javascript/mastodon/components/router.tsx @@ -14,9 +14,14 @@ import { createBrowserHistory } from 'history'; import { layoutFromWindow } from 'mastodon/is_mobile'; import { isDevelopment } from 'mastodon/utils/environment'; +import type { FocusTarget } from './navigation_focus_target'; + interface MastodonLocationState { fromMastodon?: boolean; mastodonModalKey?: string; + // Controls which element is focused after a navigation. + // Set to `false` to prevent navigation focus. + focusTarget?: FocusTarget; // Prevent the rightmost column in advanced UI from scrolling // into view on location changes preventMultiColumnAutoScroll?: string; diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx index 808f4d73bf..2f66f15405 100644 --- a/app/javascript/mastodon/components/status.jsx +++ b/app/javascript/mastodon/components/status.jsx @@ -33,6 +33,7 @@ import StatusContent from './status_content'; import { StatusThreadLabel } from './status_thread_label'; import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card'; import { compareUrls } from '../utils/compare_urls'; +import { FOCUS_TARGET } from './navigation_focus_target'; const domParser = new DOMParser(); @@ -311,9 +312,9 @@ class Status extends ImmutablePureComponent { window.open(path, '_blank', 'noopener'); } else { if (history.location.pathname.replace('/deck/', '/') === path) { - history.replace(path); + history.replace(path, {focusTarget: FOCUS_TARGET.POST}); } else { - history.push(path); + history.push(path, {focusTarget: FOCUS_TARGET.POST}); } } }; diff --git a/app/javascript/mastodon/containers/mastodon.jsx b/app/javascript/mastodon/containers/mastodon.jsx index d6df09db49..4f87a1b823 100644 --- a/app/javascript/mastodon/containers/mastodon.jsx +++ b/app/javascript/mastodon/containers/mastodon.jsx @@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from 'react-redux'; import { hydrateStore } from 'mastodon/actions/store'; import { connectUserStream } from 'mastodon/actions/streaming'; import ErrorBoundary from 'mastodon/components/error_boundary'; +import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target'; import { Router } from 'mastodon/components/router'; import UI from 'mastodon/features/ui'; import { IdentityContext, createIdentityContext } from 'mastodon/identity_context'; @@ -49,7 +50,9 @@ export default class Mastodon extends PureComponent { - + + + diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 83f5bf6025..d60ef9e610 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -8,10 +8,13 @@ import { Helmet } from '@unhead/react/helmet'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; +import { domain } from 'mastodon/initial_state'; + import { injectIntl } from '@/mastodon/components/intl'; import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server'; import { Account } from 'mastodon/components/account'; import Column from 'mastodon/components/column'; +import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target'; import { ServerHeroImage } from 'mastodon/components/server_hero_image'; import { Skeleton } from 'mastodon/components/skeleton'; import { LinkFooter} from 'mastodon/features/ui/components/link_footer'; @@ -91,7 +94,9 @@ class About extends PureComponent { srcSet={Object.keys(server.item?.thumbnail.versions ?? {}).map((key) => `${server.item?.thumbnail.versions && server.item.thumbnail.versions[key]} ${key.replace('@', '')}`).join(', ')} className='about__header__hero' /> -

{isLoading ? : server.domain}

+ + {isLoading ? : domain} +

Mastodon }} />

diff --git a/app/javascript/mastodon/features/account_edit/components/field_actions.tsx b/app/javascript/mastodon/features/account_edit/components/field_actions.tsx index fecdcb8eff..028f4dc803 100644 --- a/app/javascript/mastodon/features/account_edit/components/field_actions.tsx +++ b/app/javascript/mastodon/features/account_edit/components/field_actions.tsx @@ -26,6 +26,7 @@ export const AccountFieldActions: FC<{ id: string }> = ({ id }) => { openModal({ modalType: 'ACCOUNT_EDIT_FIELD_EDIT', modalProps: { fieldKey: id }, + ignoreFocus: true, }), ); }, [dispatch, id]); diff --git a/app/javascript/mastodon/features/account_edit/index.tsx b/app/javascript/mastodon/features/account_edit/index.tsx index d00473043e..6ef8997f7b 100644 --- a/app/javascript/mastodon/features/account_edit/index.tsx +++ b/app/javascript/mastodon/features/account_edit/index.tsx @@ -137,16 +137,28 @@ export const AccountEdit: FC = () => { ); const handleOpenModal = useCallback( - (type: ModalType, props?: Record) => { - dispatch(openModal({ modalType: type, modalProps: props ?? {} })); + ( + type: ModalType, + { + modalProps = {}, + ignoreFocus = false, + }: { modalProps?: Record; ignoreFocus?: boolean } = {}, + ) => { + dispatch( + openModal({ + modalType: type, + modalProps, + ignoreFocus, + }), + ); }, [dispatch], ); const handleNameEdit = useCallback(() => { - handleOpenModal('ACCOUNT_EDIT_NAME'); + handleOpenModal('ACCOUNT_EDIT_NAME', { ignoreFocus: true }); }, [handleOpenModal]); const handleBioEdit = useCallback(() => { - handleOpenModal('ACCOUNT_EDIT_BIO'); + handleOpenModal('ACCOUNT_EDIT_BIO', { ignoreFocus: true }); }, [handleOpenModal]); const handleCustomFieldAdd = useCallback(() => { handleOpenModal('ACCOUNT_EDIT_FIELD_EDIT'); diff --git a/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx b/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx index baf0c70f76..abb0a28ee3 100644 --- a/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx +++ b/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx @@ -10,6 +10,7 @@ import { ModalShellActions, ModalShellBody, } from '@/mastodon/components/modal_shell'; +import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target'; import { useFieldHtml } from '../hooks/useFieldHtml'; @@ -24,12 +25,14 @@ export const AccountFieldModal: FC<{ return ( - + + + ({ message: state.getIn(['server', 'server', 'item', 'registrations', 'message']), @@ -42,7 +43,9 @@ class ClosedRegistrationsModal extends ImmutablePureComponent { return (
-

+ + +

-

+

{closedRegistrationsMessage}
-

+

-

+ {isNew ? ( )} -

+
-

{pageTitleHtml}

+ + {pageTitleHtml} + {intl.formatMessage(createdByTabMessage, { diff --git a/app/javascript/mastodon/features/interaction_modal/index.tsx b/app/javascript/mastodon/features/interaction_modal/index.tsx index ef1d3e1111..128fb3ff5e 100644 --- a/app/javascript/mastodon/features/interaction_modal/index.tsx +++ b/app/javascript/mastodon/features/interaction_modal/index.tsx @@ -8,6 +8,7 @@ import { escapeRegExp } from 'lodash'; import { useDebouncedCallback } from 'use-debounce'; import { DisplayName } from '@/mastodon/components/display_name'; +import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target'; import { openModal, closeModal } from 'mastodon/actions/modal'; import { apiRequest } from 'mastodon/api'; import { Button } from 'mastodon/components/button'; @@ -474,12 +475,12 @@ const InteractionModal: React.FC<{ return (
-

+ -

+

{intent === 'follow' ? ( = ({ const path = `/@${account.acct}/${statusId}`; if (button === 0 && !(ctrlKey || metaKey)) { - history.push(path); + history.push(path, { focusTarget: FOCUS_TARGET.POST }); } else if (button === 1 || (button === 0 && (ctrlKey || metaKey))) { window.open(path, '_blank', 'noopener'); } diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx index db18964a3b..69a62a3663 100644 --- a/app/javascript/mastodon/features/status/index.jsx +++ b/app/javascript/mastodon/features/status/index.jsx @@ -72,6 +72,7 @@ import ActionBar from './components/action_bar'; import { DetailedStatus } from './components/detailed_status'; import { RefreshController } from './components/refresh_controller'; import { quoteComposeById } from '@/mastodon/actions/compose_typed'; +import { FOCUS_TARGET, NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target'; const messages = defineMessages({ revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' }, @@ -585,7 +586,13 @@ class Status extends ImmutablePureComponent { {ancestors} -

+ -
+ {descendants} diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx b/app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx index d4e248f443..1a63c75612 100644 --- a/app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx +++ b/app/javascript/mastodon/features/ui/components/__tests__/column-test.jsx @@ -1,6 +1,7 @@ import { render, fireEvent, screen } from '@/testing/rendering'; import Column from '../column'; +import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target'; const fakeIcon = () => ; @@ -9,9 +10,11 @@ describe('', () => { it('runs the scroll animation if the column contains scrollable content', () => { const scrollToMock = vi.fn(); const { container } = render( - -
- , + + +
+ + , ); container.querySelector('.scrollable').scrollTo = scrollToMock; fireEvent.click(screen.getByText('notifications')); @@ -19,7 +22,11 @@ describe('', () => { }); it('does not try to scroll if there is no scrollable content', () => { - render(); + render( + + + + ); fireEvent.click(screen.getByText('notifications')); }); }); diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx index 1dbc19ea3b..3dfd3124d1 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; +import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target'; import { Button } from 'mastodon/components/button'; import { ModalShell, @@ -67,7 +68,13 @@ export const ConfirmationModal: React.FC< return ( -

{title}

+ {noFocusButton ? ( + + {title} + + ) : ( +

{title}

+ )} {message &&

{message}

} {extraContent ?? children} diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 73ff427e6f..06692d54ee 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -187,7 +187,7 @@ class SwitchingColumnsArea extends PureComponent { - + {singleColumn ? : null} {singleColumn && pathName.startsWith('/deck/') ? : null} diff --git a/app/javascript/mastodon/reducers/modal.ts b/app/javascript/mastodon/reducers/modal.ts index dfdff7cf03..a8d771f4bf 100644 --- a/app/javascript/mastodon/reducers/modal.ts +++ b/app/javascript/mastodon/reducers/modal.ts @@ -17,8 +17,10 @@ const Modal = ImmutableRecord({ modalProps: ImmutableRecord({})(), }); +export const IGNORE_FOCUS_ON_OPEN = 'on-open'; + interface ModalState { - ignoreFocus: boolean; + ignoreFocus: boolean | typeof IGNORE_FOCUS_ON_OPEN; stack: Stack>; } @@ -53,9 +55,10 @@ const pushModal = ( modalType: ModalType, modalProps: ModalProps, previousModalProps?: ModalProps, + ignoreFocusOnOpen = false, ): State => { return state.withMutations((record) => { - record.set('ignoreFocus', false); + record.set('ignoreFocus', ignoreFocusOnOpen ? IGNORE_FOCUS_ON_OPEN : false); record.update('stack', (stack) => { let tmp = stack; @@ -92,6 +95,7 @@ export const modalReducer: Reducer = (state = initialState, action) => { action.payload.modalType, action.payload.modalProps, action.payload.previousModalProps, + action.payload.ignoreFocus, ); else if (closeModal.match(action)) return popModal(state, action.payload); // TODO: type those actions diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index badbcba385..f228fcf0df 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -9146,6 +9146,8 @@ noscript { padding-bottom: 32px; } + h1, + h2, h3 { font-size: 22px; line-height: 33px; @@ -9176,6 +9178,8 @@ noscript { &__lead { margin-bottom: 20px; + h1, + h2, h3 { margin-bottom: 15px; } @@ -9251,6 +9255,8 @@ noscript { flex: 1; box-sizing: border-box; + h1, + h2, h3 { margin-bottom: 20px; }