[Glitch] Manage focus on navigation

Port bcb8553e01270bdb56827a701c4359d39643aa2c to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
diondiondion 2026-06-10 14:34:48 +02:00 committed by Claire
parent 5893554a3a
commit 4cb1bf73c9
21 changed files with 254 additions and 33 deletions

View File

@ -10,6 +10,7 @@ interface OpenModalPayload {
modalType: ModalType;
modalProps: ModalProps;
previousModalProps?: ModalProps;
ignoreFocus?: boolean;
}
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');

View File

@ -19,6 +19,7 @@ import { useIdentity } from 'flavours/glitch/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<Props> = ({
<div className={headingClassName}>
{backButton}
{hasTitle && (
<h1 className='column-header__title-wrapper'>
<NavigationFocusTarget
as='h1'
className='column-header__title-wrapper'
>
{onClick ? (
<button
onClick={handleTitleClick}
@ -304,7 +308,7 @@ export const ColumnHeader: React.FC<Props> = ({
{titleContents}
</span>
)}
</h1>
</NavigationFocusTarget>
)}
<div className='column-header__buttons'>

View File

@ -7,6 +7,7 @@ import { multiply } from 'color-blend';
import { createBrowserHistory } from 'history';
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
import { IGNORE_FOCUS_ON_OPEN } from '../reducers/modal';
class ModalRoot extends PureComponent {
@ -22,7 +23,10 @@ class ModalRoot extends PureComponent {
}),
]),
noEsc: PropTypes.bool,
ignoreFocus: PropTypes.bool,
ignoreFocus: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.string, // 'on-open', see IGNORE_FOCUS_ON_OPEN
]),
...WithOptionalRouterPropTypes,
};
@ -123,7 +127,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,
});
}
}

View File

@ -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<React.MutableRefObject<FocusTarget> | 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<FocusTarget>(false);
const previousLocationRef = useRef<
| (Pick<MastodonLocation, 'pathname' | 'search'> & {
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 (
<FocusTargetContext.Provider value={focusTargetRef}>
{children}
</FocusTargetContext.Provider>
);
};
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 (
<Component ref={focusOnNavigation} tabIndex={-1} {...otherProps}>
{children}
</Component>
);
});

View File

@ -14,9 +14,14 @@ import { createBrowserHistory } from 'history';
import { layoutFromWindow } from 'flavours/glitch/is_mobile';
import { isDevelopment } from 'flavours/glitch/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;

View File

@ -32,6 +32,7 @@ import StatusIcons from './status_icons';
import StatusPrepend from './status_prepend';
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();
@ -392,9 +393,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});
}
}
};

View File

@ -9,6 +9,7 @@ import { checkDeprecatedLocalSettings } from 'flavours/glitch/actions/local_sett
import { hydrateStore } from 'flavours/glitch/actions/store';
import { connectUserStream } from 'flavours/glitch/actions/streaming';
import ErrorBoundary from 'flavours/glitch/components/error_boundary';
import { FocusTargetProvider } from '@/flavours/glitch/components/navigation_focus_target';
import { Router } from 'flavours/glitch/components/router';
import UI from 'flavours/glitch/features/ui';
import { IdentityContext, createIdentityContext } from 'flavours/glitch/identity_context';
@ -53,7 +54,9 @@ export default class Mastodon extends PureComponent {
<ErrorBoundary>
<Router>
<ScrollContext>
<Route path='/' component={UI} />
<FocusTargetProvider>
<Route path='/' component={UI} />
</FocusTargetProvider>
</ScrollContext>
<BodyScrollLock />
</Router>

View File

@ -8,10 +8,13 @@ import { Helmet } from '@unhead/react/helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { domain } from 'flavours/glitch/initial_state';
import { injectIntl } from '@/flavours/glitch/components/intl';
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'flavours/glitch/actions/server';
import { Account } from 'flavours/glitch/components/account';
import Column from 'flavours/glitch/components/column';
import { NavigationFocusTarget } from 'flavours/glitch/components/navigation_focus_target';
import { ServerHeroImage } from 'flavours/glitch/components/server_hero_image';
import { Skeleton } from 'flavours/glitch/components/skeleton';
import { LinkFooter} from 'flavours/glitch/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'
/>
<h1>{isLoading ? <Skeleton width='10ch' /> : server.domain}</h1>
<NavigationFocusTarget as='h1'>
{isLoading ? <Skeleton width='10ch' /> : domain}
</NavigationFocusTarget>
<p><FormattedMessage id='about.powered_by' defaultMessage='Decentralized social media powered by {mastodon}' values={{ mastodon: <a href='https://joinmastodon.org' className='about__mail' target='_blank' rel='noopener'>Mastodon</a> }} /></p>
</div>

View File

@ -26,6 +26,7 @@ export const AccountFieldActions: FC<{ id: string }> = ({ id }) => {
openModal({
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
modalProps: { fieldKey: id },
ignoreFocus: true,
}),
);
}, [dispatch, id]);

View File

@ -137,16 +137,28 @@ export const AccountEdit: FC = () => {
);
const handleOpenModal = useCallback(
(type: ModalType, props?: Record<string, unknown>) => {
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
(
type: ModalType,
{
modalProps = {},
ignoreFocus = false,
}: { modalProps?: Record<string, unknown>; 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');

View File

@ -10,6 +10,7 @@ import {
ModalShellActions,
ModalShellBody,
} from '@/flavours/glitch/components/modal_shell';
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
import { useFieldHtml } from '../hooks/useFieldHtml';
@ -24,12 +25,14 @@ export const AccountFieldModal: FC<{
return (
<ModalShell>
<ModalShellBody>
<EmojiHTML
as='h2'
htmlString={field.name_emojified}
onElement={handleLabelElement}
className={classes.fieldName}
/>
<NavigationFocusTarget as='h1'>
<EmojiHTML
as='span'
htmlString={field.name_emojified}
onElement={handleLabelElement}
className={classes.fieldName}
/>
</NavigationFocusTarget>
<EmojiHTML
as='p'
htmlString={field.value_emojified}

View File

@ -5,6 +5,7 @@ import { connect } from 'react-redux';
import { fetchServer } from 'flavours/glitch/actions/server';
import { domain } from 'flavours/glitch/initial_state';
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
const mapStateToProps = state => ({
message: state.getIn(['server', 'server', 'item', 'registrations', 'message']),
@ -42,7 +43,9 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
return (
<div className='modal-root__modal interaction-modal'>
<div className='interaction-modal__lead'>
<h3><FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' /></h3>
<NavigationFocusTarget as='h1'>
<FormattedMessage id='closed_registrations_modal.title' defaultMessage='Signing up on Mastodon' />
</NavigationFocusTarget>
<p>
<FormattedMessage
id='closed_registrations_modal.preamble'
@ -53,12 +56,12 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
<div className='interaction-modal__choices'>
<div className='interaction-modal__choices__choice'>
<h3><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h3>
<h2><FormattedMessage id='interaction_modal.on_this_server' defaultMessage='On this server' /></h2>
{closedRegistrationsMessage}
</div>
<div className='interaction-modal__choices__choice'>
<h3><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h3>
<h2><FormattedMessage id='interaction_modal.on_another_server' defaultMessage='On a different server' /></h2>
<p className='prose'>
<FormattedMessage
id='closed_registrations.other_server_instructions'

View File

@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { useLocation } from 'react-router';
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
import { me } from '@/flavours/glitch/initial_state';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { changeCompose, focusCompose } from 'flavours/glitch/actions/compose';
@ -66,7 +67,7 @@ export const CollectionShareModal: React.FC<{
return (
<ModalShell>
<ModalShellBody>
<h1 className={classes.heading}>
<NavigationFocusTarget as='h1' className={classes.heading}>
{isNew ? (
<FormattedMessage
id='collection.share_modal.title_new'
@ -78,7 +79,7 @@ export const CollectionShareModal: React.FC<{
defaultMessage='Share collection'
/>
)}
</h1>
</NavigationFocusTarget>
<IconButton
title={intl.formatMessage({

View File

@ -4,6 +4,7 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
import { Helmet } from '@unhead/react/helmet';
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
import { Column } from 'flavours/glitch/components/column';
import { ColumnHeader } from 'flavours/glitch/components/column_header';
import { DisplayNameSimple } from 'flavours/glitch/components/display_name/simple';
@ -74,7 +75,9 @@ export const Collections: React.FC<{
<Scrollable>
<header className={classes.header}>
<h1 className={classes.heading}>{pageTitleHtml}</h1>
<NavigationFocusTarget as='h1' className={classes.heading}>
{pageTitleHtml}
</NavigationFocusTarget>
<TabList plain>
<TabLink exact to={`/@${account?.acct}/collections`}>
{intl.formatMessage(createdByTabMessage, {

View File

@ -8,6 +8,7 @@ import { escapeRegExp } from 'lodash';
import { useDebouncedCallback } from 'use-debounce';
import { DisplayName } from '@/flavours/glitch/components/display_name';
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
import { openModal, closeModal } from 'flavours/glitch/actions/modal';
import { apiRequest } from 'flavours/glitch/api';
import { Button } from 'flavours/glitch/components/button';
@ -474,12 +475,12 @@ const InteractionModal: React.FC<{
return (
<div className='modal-root__modal interaction-modal'>
<div className='interaction-modal__lead'>
<h3>
<NavigationFocusTarget as='h1'>
<FormattedMessage
id='interaction_modal.title'
defaultMessage='Sign in to continue'
/>
</h3>
</NavigationFocusTarget>
<p>
{intent === 'follow' ? (
<FormattedMessage

View File

@ -8,6 +8,7 @@ import type { List as ImmutableList, RecordOf } from 'immutable';
import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import { FOCUS_TARGET } from '@/flavours/glitch/components/navigation_focus_target';
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
import { toggleStatusSpoilers } from 'flavours/glitch/actions/statuses';
@ -67,7 +68,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
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');
}

View File

@ -66,6 +66,7 @@ import ActionBar from './components/action_bar';
import { DetailedStatus } from './components/detailed_status';
import { RefreshController } from './components/refresh_controller';
import { quoteComposeById } from '@/flavours/glitch/actions/compose_typed';
import { FOCUS_TARGET, NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
const messages = defineMessages({
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
@ -630,7 +631,13 @@ class Status extends ImmutablePureComponent {
{ancestors}
<Hotkeys handlers={handlers}>
<div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader({intl, status, expanded: isExpanded})} ref={this.setStatusRef}>
<NavigationFocusTarget
as='div'
focusTargetName={FOCUS_TARGET.POST}
className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)}
tabIndex={0}
aria-label={textForScreenReader({intl, status, expanded: isExpanded})} ref={this.setStatusRef}
>
<DetailedStatus
key={`details-${status.get('id')}`}
status={status}
@ -669,7 +676,7 @@ class Status extends ImmutablePureComponent {
onPin={this.handlePin}
onEmbed={this.handleEmbed}
/>
</div>
</NavigationFocusTarget>
</Hotkeys>
{descendants}

View File

@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { FormattedMessage, defineMessages } from 'react-intl';
import { NavigationFocusTarget } from '@/flavours/glitch/components/navigation_focus_target';
import { Button } from 'flavours/glitch/components/button';
import {
ModalShell,
@ -75,7 +76,13 @@ export const ConfirmationModal: React.FC<
return (
<ModalShell onSubmit={handleClick}>
<ModalShellBody className={className}>
<h1 id={titleId}>{title}</h1>
{noFocusButton ? (
<NavigationFocusTarget as='h1' id={titleId}>
{title}
</NavigationFocusTarget>
) : (
<h1>{title}</h1>
)}
{message && <p>{message}</p>}
{extraContent ?? children}

View File

@ -195,7 +195,7 @@ class SwitchingColumnsArea extends PureComponent {
<ColumnsContextProvider multiColumn={!singleColumn}>
<ColumnsArea ref={this.setRef} singleColumn={singleColumn} domain={domain} minimalShell={!signedIn && landingPage === 'overview'}>
<WrappedSwitch>
<Redirect from='/' to={{pathname: rootRedirect, state: this.props.location.state}} exact />
<Redirect from='/' to={{pathname: rootRedirect, state: {...this.props.location.state, focusTarget: false}}} exact />
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}

View File

@ -17,8 +17,10 @@ const Modal = ImmutableRecord<Modal>({
modalProps: ImmutableRecord({})(),
});
export const IGNORE_FOCUS_ON_OPEN = 'on-open';
interface ModalState {
ignoreFocus: boolean;
ignoreFocus: boolean | typeof IGNORE_FOCUS_ON_OPEN;
stack: Stack<ImmutableRecord<Modal>>;
}
@ -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> = (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

View File

@ -9487,6 +9487,8 @@ noscript {
padding-bottom: 32px;
}
h1,
h2,
h3 {
font-size: 22px;
line-height: 33px;
@ -9517,6 +9519,8 @@ noscript {
&__lead {
margin-bottom: 20px;
h1,
h2,
h3 {
margin-bottom: 15px;
}
@ -9592,6 +9596,8 @@ noscript {
flex: 1;
box-sizing: border-box;
h1,
h2,
h3 {
margin-bottom: 20px;
}