[Accessibility] Manage focus on navigation (#39350)
This commit is contained in:
1 parent
bff9f0eb6d
commit
bcb8553e01
22 files changed
+265
-37
No files matched your search
@@ -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'
|
||||
/>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export const AccountFieldActions: FC<{ id: string }> = ({ id }) => {
|
||||
openModal({
|
||||
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
|
||||
modalProps: { fieldKey: id },
|
||||
ignoreFocus: true,
|
||||
}),
|
||||
);
|
||||
}, [dispatch, id]);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 (
|
||||
<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}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { connect } from 'react-redux';
|
||||
|
||||
import { fetchServer } from 'mastodon/actions/server';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { NavigationFocusTarget } from '@/mastodon/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'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||
import { me } from '@/mastodon/initial_state';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { changeCompose, focusCompose } from 'mastodon/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({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import { Helmet } from '@unhead/react/helmet';
|
||||
|
||||
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||
import { Column } from 'mastodon/components/column';
|
||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||
import { DisplayNameSimple } from 'mastodon/components/display_name/simple';
|
||||
@@ -71,7 +72,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, {
|
||||
|
||||
@@ -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 (
|
||||
<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
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { List as ImmutableList, RecordOf } from 'immutable';
|
||||
|
||||
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
|
||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import { FOCUS_TARGET } from '@/mastodon/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 'mastodon/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');
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
<Hotkeys handlers={handlers}>
|
||||
<div className={classNames('focusable', 'detailed-status__wrapper', `detailed-status__wrapper-${status.get('visibility')}`)} tabIndex={0} aria-label={textForScreenReader({intl, status})} 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})} ref={this.setStatusRef}
|
||||
>
|
||||
<DetailedStatus
|
||||
key={`details-${status.get('id')}`}
|
||||
status={status}
|
||||
@@ -626,7 +633,7 @@ class Status extends ImmutablePureComponent {
|
||||
onPin={this.handlePin}
|
||||
onEmbed={this.handleEmbed}
|
||||
/>
|
||||
</div>
|
||||
</NavigationFocusTarget>
|
||||
</Hotkeys>
|
||||
|
||||
{descendants}
|
||||
|
||||
@@ -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 = () => <span />;
|
||||
|
||||
@@ -9,9 +10,11 @@ describe('<Column />', () => {
|
||||
it('runs the scroll animation if the column contains scrollable content', () => {
|
||||
const scrollToMock = vi.fn();
|
||||
const { container } = render(
|
||||
<Column heading='notifications' icon='notifications' iconComponent={fakeIcon}>
|
||||
<div className='scrollable' />
|
||||
</Column>,
|
||||
<FocusTargetProvider>
|
||||
<Column heading='notifications' icon='notifications' iconComponent={fakeIcon}>
|
||||
<div className='scrollable' />
|
||||
</Column>
|
||||
</FocusTargetProvider>,
|
||||
);
|
||||
container.querySelector('.scrollable').scrollTo = scrollToMock;
|
||||
fireEvent.click(screen.getByText('notifications'));
|
||||
@@ -19,7 +22,11 @@ describe('<Column />', () => {
|
||||
});
|
||||
|
||||
it('does not try to scroll if there is no scrollable content', () => {
|
||||
render(<Column heading='notifications' icon='notifications' iconComponent={fakeIcon} />);
|
||||
render(
|
||||
<FocusTargetProvider>
|
||||
<Column heading='notifications' icon='notifications' iconComponent={fakeIcon} />
|
||||
</FocusTargetProvider>
|
||||
);
|
||||
fireEvent.click(screen.getByText('notifications'));
|
||||
});
|
||||
});
|
||||
|
||||
+8
-1
@@ -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 (
|
||||
<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}
|
||||
|
||||
@@ -187,7 +187,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}
|
||||
|
||||
Reference in new issue
Block a user