[Accessibility] Manage focus on navigation (#39350)
This commit is contained in:
parent
bff9f0eb6d
commit
bcb8553e01
@ -10,6 +10,7 @@ interface OpenModalPayload {
|
|||||||
modalType: ModalType;
|
modalType: ModalType;
|
||||||
modalProps: ModalProps;
|
modalProps: ModalProps;
|
||||||
previousModalProps?: ModalProps;
|
previousModalProps?: ModalProps;
|
||||||
|
ignoreFocus?: boolean;
|
||||||
}
|
}
|
||||||
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
|
export const openModal = createAction<OpenModalPayload>('MODAL_OPEN');
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { useIdentity } from 'mastodon/identity_context';
|
|||||||
import { useColumnIndexContext } from '../features/ui/components/columns_area';
|
import { useColumnIndexContext } from '../features/ui/components/columns_area';
|
||||||
import { getColumnSkipLinkId } from '../features/ui/components/skip_links';
|
import { getColumnSkipLinkId } from '../features/ui/components/skip_links';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from './navigation_focus_target';
|
||||||
import { useAppHistory } from './router';
|
import { useAppHistory } from './router';
|
||||||
|
|
||||||
export const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
@ -285,7 +286,10 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||||||
<div className={headingClassName}>
|
<div className={headingClassName}>
|
||||||
{backButton}
|
{backButton}
|
||||||
{hasTitle && (
|
{hasTitle && (
|
||||||
<h1 className='column-header__title-wrapper'>
|
<NavigationFocusTarget
|
||||||
|
as='h1'
|
||||||
|
className='column-header__title-wrapper'
|
||||||
|
>
|
||||||
{onClick ? (
|
{onClick ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleTitleClick}
|
onClick={handleTitleClick}
|
||||||
@ -304,7 +308,7 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||||||
{titleContents}
|
{titleContents}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h1>
|
</NavigationFocusTarget>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='column-header__buttons'>
|
<div className='column-header__buttons'>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { multiply } from 'color-blend';
|
|||||||
import { createBrowserHistory } from 'history';
|
import { createBrowserHistory } from 'history';
|
||||||
|
|
||||||
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
|
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
|
||||||
|
import { IGNORE_FOCUS_ON_OPEN } from '../reducers/modal';
|
||||||
|
|
||||||
class ModalRoot extends PureComponent {
|
class ModalRoot extends PureComponent {
|
||||||
|
|
||||||
@ -21,7 +22,10 @@ class ModalRoot extends PureComponent {
|
|||||||
b: PropTypes.number,
|
b: PropTypes.number,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
ignoreFocus: PropTypes.bool,
|
ignoreFocus: PropTypes.oneOfType([
|
||||||
|
PropTypes.bool,
|
||||||
|
PropTypes.string, // 'on-open', see IGNORE_FOCUS_ON_OPEN
|
||||||
|
]),
|
||||||
...WithOptionalRouterPropTypes,
|
...WithOptionalRouterPropTypes,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -118,7 +122,11 @@ class ModalRoot extends PureComponent {
|
|||||||
_ensureHistoryBuffer () {
|
_ensureHistoryBuffer () {
|
||||||
const { pathname, search, hash, state } = this.history.location;
|
const { pathname, search, hash, state } = this.history.location;
|
||||||
if (!state || state.mastodonModalKey !== this._modalHistoryKey) {
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -14,9 +14,14 @@ import { createBrowserHistory } from 'history';
|
|||||||
import { layoutFromWindow } from 'mastodon/is_mobile';
|
import { layoutFromWindow } from 'mastodon/is_mobile';
|
||||||
import { isDevelopment } from 'mastodon/utils/environment';
|
import { isDevelopment } from 'mastodon/utils/environment';
|
||||||
|
|
||||||
|
import type { FocusTarget } from './navigation_focus_target';
|
||||||
|
|
||||||
interface MastodonLocationState {
|
interface MastodonLocationState {
|
||||||
fromMastodon?: boolean;
|
fromMastodon?: boolean;
|
||||||
mastodonModalKey?: string;
|
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
|
// Prevent the rightmost column in advanced UI from scrolling
|
||||||
// into view on location changes
|
// into view on location changes
|
||||||
preventMultiColumnAutoScroll?: string;
|
preventMultiColumnAutoScroll?: string;
|
||||||
|
|||||||
@ -33,6 +33,7 @@ import StatusContent from './status_content';
|
|||||||
import { StatusThreadLabel } from './status_thread_label';
|
import { StatusThreadLabel } from './status_thread_label';
|
||||||
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
|
import { CollectionPreviewCard } from '../features/collections/components/collection_preview_card';
|
||||||
import { compareUrls } from '../utils/compare_urls';
|
import { compareUrls } from '../utils/compare_urls';
|
||||||
|
import { FOCUS_TARGET } from './navigation_focus_target';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
@ -311,9 +312,9 @@ class Status extends ImmutablePureComponent {
|
|||||||
window.open(path, '_blank', 'noopener');
|
window.open(path, '_blank', 'noopener');
|
||||||
} else {
|
} else {
|
||||||
if (history.location.pathname.replace('/deck/', '/') === path) {
|
if (history.location.pathname.replace('/deck/', '/') === path) {
|
||||||
history.replace(path);
|
history.replace(path, {focusTarget: FOCUS_TARGET.POST});
|
||||||
} else {
|
} else {
|
||||||
history.push(path);
|
history.push(path, {focusTarget: FOCUS_TARGET.POST});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { Provider as ReduxProvider } from 'react-redux';
|
|||||||
import { hydrateStore } from 'mastodon/actions/store';
|
import { hydrateStore } from 'mastodon/actions/store';
|
||||||
import { connectUserStream } from 'mastodon/actions/streaming';
|
import { connectUserStream } from 'mastodon/actions/streaming';
|
||||||
import ErrorBoundary from 'mastodon/components/error_boundary';
|
import ErrorBoundary from 'mastodon/components/error_boundary';
|
||||||
|
import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target';
|
||||||
import { Router } from 'mastodon/components/router';
|
import { Router } from 'mastodon/components/router';
|
||||||
import UI from 'mastodon/features/ui';
|
import UI from 'mastodon/features/ui';
|
||||||
import { IdentityContext, createIdentityContext } from 'mastodon/identity_context';
|
import { IdentityContext, createIdentityContext } from 'mastodon/identity_context';
|
||||||
@ -49,7 +50,9 @@ export default class Mastodon extends PureComponent {
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Router>
|
<Router>
|
||||||
<ScrollContext>
|
<ScrollContext>
|
||||||
<Route path='/' component={UI} />
|
<FocusTargetProvider>
|
||||||
|
<Route path='/' component={UI} />
|
||||||
|
</FocusTargetProvider>
|
||||||
</ScrollContext>
|
</ScrollContext>
|
||||||
<BodyScrollLock />
|
<BodyScrollLock />
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@ -8,10 +8,13 @@ import { Helmet } from '@unhead/react/helmet';
|
|||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { domain } from 'mastodon/initial_state';
|
||||||
|
|
||||||
import { injectIntl } from '@/mastodon/components/intl';
|
import { injectIntl } from '@/mastodon/components/intl';
|
||||||
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
|
import { fetchServer, fetchExtendedDescription, fetchDomainBlocks } from 'mastodon/actions/server';
|
||||||
import { Account } from 'mastodon/components/account';
|
import { Account } from 'mastodon/components/account';
|
||||||
import Column from 'mastodon/components/column';
|
import Column from 'mastodon/components/column';
|
||||||
|
import { NavigationFocusTarget } from 'mastodon/components/navigation_focus_target';
|
||||||
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
|
||||||
import { Skeleton } from 'mastodon/components/skeleton';
|
import { Skeleton } from 'mastodon/components/skeleton';
|
||||||
import { LinkFooter} from 'mastodon/features/ui/components/link_footer';
|
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(', ')}
|
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'
|
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>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export const AccountFieldActions: FC<{ id: string }> = ({ id }) => {
|
|||||||
openModal({
|
openModal({
|
||||||
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
|
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
|
||||||
modalProps: { fieldKey: id },
|
modalProps: { fieldKey: id },
|
||||||
|
ignoreFocus: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [dispatch, id]);
|
}, [dispatch, id]);
|
||||||
|
|||||||
@ -137,16 +137,28 @@ export const AccountEdit: FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenModal = useCallback(
|
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],
|
[dispatch],
|
||||||
);
|
);
|
||||||
const handleNameEdit = useCallback(() => {
|
const handleNameEdit = useCallback(() => {
|
||||||
handleOpenModal('ACCOUNT_EDIT_NAME');
|
handleOpenModal('ACCOUNT_EDIT_NAME', { ignoreFocus: true });
|
||||||
}, [handleOpenModal]);
|
}, [handleOpenModal]);
|
||||||
const handleBioEdit = useCallback(() => {
|
const handleBioEdit = useCallback(() => {
|
||||||
handleOpenModal('ACCOUNT_EDIT_BIO');
|
handleOpenModal('ACCOUNT_EDIT_BIO', { ignoreFocus: true });
|
||||||
}, [handleOpenModal]);
|
}, [handleOpenModal]);
|
||||||
const handleCustomFieldAdd = useCallback(() => {
|
const handleCustomFieldAdd = useCallback(() => {
|
||||||
handleOpenModal('ACCOUNT_EDIT_FIELD_EDIT');
|
handleOpenModal('ACCOUNT_EDIT_FIELD_EDIT');
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
ModalShellActions,
|
ModalShellActions,
|
||||||
ModalShellBody,
|
ModalShellBody,
|
||||||
} from '@/mastodon/components/modal_shell';
|
} from '@/mastodon/components/modal_shell';
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
|
|
||||||
import { useFieldHtml } from '../hooks/useFieldHtml';
|
import { useFieldHtml } from '../hooks/useFieldHtml';
|
||||||
|
|
||||||
@ -24,12 +25,14 @@ export const AccountFieldModal: FC<{
|
|||||||
return (
|
return (
|
||||||
<ModalShell>
|
<ModalShell>
|
||||||
<ModalShellBody>
|
<ModalShellBody>
|
||||||
<EmojiHTML
|
<NavigationFocusTarget as='h1'>
|
||||||
as='h2'
|
<EmojiHTML
|
||||||
htmlString={field.name_emojified}
|
as='span'
|
||||||
onElement={handleLabelElement}
|
htmlString={field.name_emojified}
|
||||||
className={classes.fieldName}
|
onElement={handleLabelElement}
|
||||||
/>
|
className={classes.fieldName}
|
||||||
|
/>
|
||||||
|
</NavigationFocusTarget>
|
||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
as='p'
|
as='p'
|
||||||
htmlString={field.value_emojified}
|
htmlString={field.value_emojified}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { connect } from 'react-redux';
|
|||||||
|
|
||||||
import { fetchServer } from 'mastodon/actions/server';
|
import { fetchServer } from 'mastodon/actions/server';
|
||||||
import { domain } from 'mastodon/initial_state';
|
import { domain } from 'mastodon/initial_state';
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
message: state.getIn(['server', 'server', 'item', 'registrations', 'message']),
|
message: state.getIn(['server', 'server', 'item', 'registrations', 'message']),
|
||||||
@ -42,7 +43,9 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
|
|||||||
return (
|
return (
|
||||||
<div className='modal-root__modal interaction-modal'>
|
<div className='modal-root__modal interaction-modal'>
|
||||||
<div className='interaction-modal__lead'>
|
<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>
|
<p>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='closed_registrations_modal.preamble'
|
id='closed_registrations_modal.preamble'
|
||||||
@ -53,12 +56,12 @@ class ClosedRegistrationsModal extends ImmutablePureComponent {
|
|||||||
|
|
||||||
<div className='interaction-modal__choices'>
|
<div className='interaction-modal__choices'>
|
||||||
<div className='interaction-modal__choices__choice'>
|
<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}
|
{closedRegistrationsMessage}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='interaction-modal__choices__choice'>
|
<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'>
|
<p className='prose'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='closed_registrations.other_server_instructions'
|
id='closed_registrations.other_server_instructions'
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
|||||||
|
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
import { me } from '@/mastodon/initial_state';
|
import { me } from '@/mastodon/initial_state';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
import { changeCompose, focusCompose } from 'mastodon/actions/compose';
|
import { changeCompose, focusCompose } from 'mastodon/actions/compose';
|
||||||
@ -66,7 +67,7 @@ export const CollectionShareModal: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<ModalShell>
|
<ModalShell>
|
||||||
<ModalShellBody>
|
<ModalShellBody>
|
||||||
<h1 className={classes.heading}>
|
<NavigationFocusTarget as='h1' className={classes.heading}>
|
||||||
{isNew ? (
|
{isNew ? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='collection.share_modal.title_new'
|
id='collection.share_modal.title_new'
|
||||||
@ -78,7 +79,7 @@ export const CollectionShareModal: React.FC<{
|
|||||||
defaultMessage='Share collection'
|
defaultMessage='Share collection'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</h1>
|
</NavigationFocusTarget>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { Route, Switch, useRouteMatch } from 'react-router-dom';
|
|||||||
|
|
||||||
import { Helmet } from '@unhead/react/helmet';
|
import { Helmet } from '@unhead/react/helmet';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
import { Column } from 'mastodon/components/column';
|
import { Column } from 'mastodon/components/column';
|
||||||
import { ColumnHeader } from 'mastodon/components/column_header';
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
import { DisplayNameSimple } from 'mastodon/components/display_name/simple';
|
import { DisplayNameSimple } from 'mastodon/components/display_name/simple';
|
||||||
@ -71,7 +72,9 @@ export const Collections: React.FC<{
|
|||||||
|
|
||||||
<Scrollable>
|
<Scrollable>
|
||||||
<header className={classes.header}>
|
<header className={classes.header}>
|
||||||
<h1 className={classes.heading}>{pageTitleHtml}</h1>
|
<NavigationFocusTarget as='h1' className={classes.heading}>
|
||||||
|
{pageTitleHtml}
|
||||||
|
</NavigationFocusTarget>
|
||||||
<TabList plain>
|
<TabList plain>
|
||||||
<TabLink exact to={`/@${account?.acct}/collections`}>
|
<TabLink exact to={`/@${account?.acct}/collections`}>
|
||||||
{intl.formatMessage(createdByTabMessage, {
|
{intl.formatMessage(createdByTabMessage, {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { escapeRegExp } from 'lodash';
|
|||||||
import { useDebouncedCallback } from 'use-debounce';
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
import { DisplayName } from '@/mastodon/components/display_name';
|
import { DisplayName } from '@/mastodon/components/display_name';
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
import { openModal, closeModal } from 'mastodon/actions/modal';
|
import { openModal, closeModal } from 'mastodon/actions/modal';
|
||||||
import { apiRequest } from 'mastodon/api';
|
import { apiRequest } from 'mastodon/api';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
@ -474,12 +475,12 @@ const InteractionModal: React.FC<{
|
|||||||
return (
|
return (
|
||||||
<div className='modal-root__modal interaction-modal'>
|
<div className='modal-root__modal interaction-modal'>
|
||||||
<div className='interaction-modal__lead'>
|
<div className='interaction-modal__lead'>
|
||||||
<h3>
|
<NavigationFocusTarget as='h1'>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='interaction_modal.title'
|
id='interaction_modal.title'
|
||||||
defaultMessage='Sign in to continue'
|
defaultMessage='Sign in to continue'
|
||||||
/>
|
/>
|
||||||
</h3>
|
</NavigationFocusTarget>
|
||||||
<p>
|
<p>
|
||||||
{intent === 'follow' ? (
|
{intent === 'follow' ? (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type { List as ImmutableList, RecordOf } from 'immutable';
|
|||||||
|
|
||||||
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
|
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
|
||||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
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 BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
||||||
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
||||||
import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
||||||
@ -67,7 +68,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||||||
const path = `/@${account.acct}/${statusId}`;
|
const path = `/@${account.acct}/${statusId}`;
|
||||||
|
|
||||||
if (button === 0 && !(ctrlKey || metaKey)) {
|
if (button === 0 && !(ctrlKey || metaKey)) {
|
||||||
history.push(path);
|
history.push(path, { focusTarget: FOCUS_TARGET.POST });
|
||||||
} else if (button === 1 || (button === 0 && (ctrlKey || metaKey))) {
|
} else if (button === 1 || (button === 0 && (ctrlKey || metaKey))) {
|
||||||
window.open(path, '_blank', 'noopener');
|
window.open(path, '_blank', 'noopener');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,6 +72,7 @@ import ActionBar from './components/action_bar';
|
|||||||
import { DetailedStatus } from './components/detailed_status';
|
import { DetailedStatus } from './components/detailed_status';
|
||||||
import { RefreshController } from './components/refresh_controller';
|
import { RefreshController } from './components/refresh_controller';
|
||||||
import { quoteComposeById } from '@/mastodon/actions/compose_typed';
|
import { quoteComposeById } from '@/mastodon/actions/compose_typed';
|
||||||
|
import { FOCUS_TARGET, NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
|
revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
|
||||||
@ -585,7 +586,13 @@ class Status extends ImmutablePureComponent {
|
|||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
<Hotkeys handlers={handlers}>
|
<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
|
<DetailedStatus
|
||||||
key={`details-${status.get('id')}`}
|
key={`details-${status.get('id')}`}
|
||||||
status={status}
|
status={status}
|
||||||
@ -626,7 +633,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
onPin={this.handlePin}
|
onPin={this.handlePin}
|
||||||
onEmbed={this.handleEmbed}
|
onEmbed={this.handleEmbed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</NavigationFocusTarget>
|
||||||
</Hotkeys>
|
</Hotkeys>
|
||||||
|
|
||||||
{descendants}
|
{descendants}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { render, fireEvent, screen } from '@/testing/rendering';
|
import { render, fireEvent, screen } from '@/testing/rendering';
|
||||||
|
|
||||||
import Column from '../column';
|
import Column from '../column';
|
||||||
|
import { FocusTargetProvider } from '@/mastodon/components/navigation_focus_target';
|
||||||
|
|
||||||
const fakeIcon = () => <span />;
|
const fakeIcon = () => <span />;
|
||||||
|
|
||||||
@ -9,9 +10,11 @@ describe('<Column />', () => {
|
|||||||
it('runs the scroll animation if the column contains scrollable content', () => {
|
it('runs the scroll animation if the column contains scrollable content', () => {
|
||||||
const scrollToMock = vi.fn();
|
const scrollToMock = vi.fn();
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<Column heading='notifications' icon='notifications' iconComponent={fakeIcon}>
|
<FocusTargetProvider>
|
||||||
<div className='scrollable' />
|
<Column heading='notifications' icon='notifications' iconComponent={fakeIcon}>
|
||||||
</Column>,
|
<div className='scrollable' />
|
||||||
|
</Column>
|
||||||
|
</FocusTargetProvider>,
|
||||||
);
|
);
|
||||||
container.querySelector('.scrollable').scrollTo = scrollToMock;
|
container.querySelector('.scrollable').scrollTo = scrollToMock;
|
||||||
fireEvent.click(screen.getByText('notifications'));
|
fireEvent.click(screen.getByText('notifications'));
|
||||||
@ -19,7 +22,11 @@ describe('<Column />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not try to scroll if there is no scrollable content', () => {
|
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'));
|
fireEvent.click(screen.getByText('notifications'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { NavigationFocusTarget } from '@/mastodon/components/navigation_focus_target';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import {
|
import {
|
||||||
ModalShell,
|
ModalShell,
|
||||||
@ -67,7 +68,13 @@ export const ConfirmationModal: React.FC<
|
|||||||
return (
|
return (
|
||||||
<ModalShell onSubmit={handleClick}>
|
<ModalShell onSubmit={handleClick}>
|
||||||
<ModalShellBody className={className}>
|
<ModalShellBody className={className}>
|
||||||
<h1 id={titleId}>{title}</h1>
|
{noFocusButton ? (
|
||||||
|
<NavigationFocusTarget as='h1' id={titleId}>
|
||||||
|
{title}
|
||||||
|
</NavigationFocusTarget>
|
||||||
|
) : (
|
||||||
|
<h1>{title}</h1>
|
||||||
|
)}
|
||||||
{message && <p>{message}</p>}
|
{message && <p>{message}</p>}
|
||||||
|
|
||||||
{extraContent ?? children}
|
{extraContent ?? children}
|
||||||
|
|||||||
@ -187,7 +187,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
<ColumnsContextProvider multiColumn={!singleColumn}>
|
<ColumnsContextProvider multiColumn={!singleColumn}>
|
||||||
<ColumnsArea ref={this.setRef} singleColumn={singleColumn} domain={domain} minimalShell={!signedIn && landingPage === 'overview'}>
|
<ColumnsArea ref={this.setRef} singleColumn={singleColumn} domain={domain} minimalShell={!signedIn && landingPage === 'overview'}>
|
||||||
<WrappedSwitch>
|
<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 ? <Redirect from='/deck' to='/home' exact /> : null}
|
||||||
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}
|
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={{...this.props.location, pathname: pathName.slice(5)}} /> : null}
|
||||||
|
|||||||
@ -17,8 +17,10 @@ const Modal = ImmutableRecord<Modal>({
|
|||||||
modalProps: ImmutableRecord({})(),
|
modalProps: ImmutableRecord({})(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const IGNORE_FOCUS_ON_OPEN = 'on-open';
|
||||||
|
|
||||||
interface ModalState {
|
interface ModalState {
|
||||||
ignoreFocus: boolean;
|
ignoreFocus: boolean | typeof IGNORE_FOCUS_ON_OPEN;
|
||||||
stack: Stack<ImmutableRecord<Modal>>;
|
stack: Stack<ImmutableRecord<Modal>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,9 +55,10 @@ const pushModal = (
|
|||||||
modalType: ModalType,
|
modalType: ModalType,
|
||||||
modalProps: ModalProps,
|
modalProps: ModalProps,
|
||||||
previousModalProps?: ModalProps,
|
previousModalProps?: ModalProps,
|
||||||
|
ignoreFocusOnOpen = false,
|
||||||
): State => {
|
): State => {
|
||||||
return state.withMutations((record) => {
|
return state.withMutations((record) => {
|
||||||
record.set('ignoreFocus', false);
|
record.set('ignoreFocus', ignoreFocusOnOpen ? IGNORE_FOCUS_ON_OPEN : false);
|
||||||
record.update('stack', (stack) => {
|
record.update('stack', (stack) => {
|
||||||
let tmp = stack;
|
let tmp = stack;
|
||||||
|
|
||||||
@ -92,6 +95,7 @@ export const modalReducer: Reducer<State> = (state = initialState, action) => {
|
|||||||
action.payload.modalType,
|
action.payload.modalType,
|
||||||
action.payload.modalProps,
|
action.payload.modalProps,
|
||||||
action.payload.previousModalProps,
|
action.payload.previousModalProps,
|
||||||
|
action.payload.ignoreFocus,
|
||||||
);
|
);
|
||||||
else if (closeModal.match(action)) return popModal(state, action.payload);
|
else if (closeModal.match(action)) return popModal(state, action.payload);
|
||||||
// TODO: type those actions
|
// TODO: type those actions
|
||||||
|
|||||||
@ -9146,6 +9146,8 @@ noscript {
|
|||||||
padding-bottom: 32px;
|
padding-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
line-height: 33px;
|
line-height: 33px;
|
||||||
@ -9176,6 +9178,8 @@ noscript {
|
|||||||
&__lead {
|
&__lead {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
@ -9251,6 +9255,8 @@ noscript {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user