Add "skip to content", "skip to navigation" links (#38006)

This commit is contained in:
diondiondion authored and GitHub committed 2026-03-02 14:37:33 +00:00
1 parent 87004ddb96
commit 816e63d2a5
10 files changed
+281 -43

No files matched your search

@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import { Children, cloneElement, useCallback } from 'react';
import { Children, cloneElement, createContext, useContext, useCallback } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@@ -53,6 +53,13 @@ const TabsBarPortal = () => {
return <div id='tabs-bar__portal' ref={setRef} />;
};
// Simple context to allow column children to know which column they're in
export const ColumnIndexContext = createContext(1);
/**
* @returns {number}
*/
export const useColumnIndexContext = () => useContext(ColumnIndexContext);
export default class ColumnsArea extends ImmutablePureComponent {
static propTypes = {
columns: ImmutablePropTypes.list.isRequired,
@@ -140,18 +147,22 @@ export default class ColumnsArea extends ImmutablePureComponent {
return (
<div className={`columns-area ${ isModalOpen ? 'unscrollable' : '' }`} ref={this.setRef}>
{columns.map(column => {
{columns.map((column, index) => {
const params = column.get('params', null) === null ? null : column.get('params').toJS();
const other = params && params.other ? params.other : {};
return (
<Bundle key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
{SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />}
</Bundle>
<ColumnIndexContext.Provider value={index} key={column.get('uuid')}>
<Bundle fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
{SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn {...other} />}
</Bundle>
</ColumnIndexContext.Provider>
);
})}
{Children.map(children, child => cloneElement(child, { multiColumn: true }))}
<ColumnIndexContext.Provider value={columns.size}>
{Children.map(children, child => cloneElement(child, { multiColumn: true }))}
</ColumnIndexContext.Provider>
</div>
);
}
@@ -0,0 +1,84 @@
import { useCallback, useId } from 'react';
import { useIntl } from 'react-intl';
import { useAppSelector } from 'mastodon/store';
import classes from './skip_links.module.scss';
export const getNavigationSkipLinkId = () => 'skip-link-target-nav';
export const getColumnSkipLinkId = (index: number) =>
`skip-link-target-content-${index}`;
export const SkipLinks: React.FC<{
multiColumn: boolean;
onFocusGettingStartedColumn: () => void;
}> = ({ multiColumn, onFocusGettingStartedColumn }) => {
const intl = useIntl();
const columnCount = useAppSelector((state) => {
const settings = state.settings as Immutable.Collection<string, unknown>;
return (settings.get('columns') as Immutable.Map<number, unknown>).size;
});
const focusMultiColumnNavbar = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
onFocusGettingStartedColumn();
},
[onFocusGettingStartedColumn],
);
return (
<ul className={classes.list}>
<li className={classes.listItem}>
<SkipLink target={getColumnSkipLinkId(1)} hotkey='1'>
{intl.formatMessage({
id: 'skip_links.skip_to_content',
defaultMessage: 'Skip to main content',
})}
</SkipLink>
</li>
<li className={classes.listItem}>
<SkipLink
target={multiColumn ? `/getting-started` : getNavigationSkipLinkId()}
onRouterLinkClick={multiColumn ? focusMultiColumnNavbar : undefined}
hotkey={multiColumn ? `${columnCount}` : '2'}
>
{intl.formatMessage({
id: 'skip_links.skip_to_navigation',
defaultMessage: 'Skip to main navigation',
})}
</SkipLink>
</li>
</ul>
);
};
const SkipLink: React.FC<{
children: string;
target: string;
onRouterLinkClick?: React.MouseEventHandler;
hotkey: string;
}> = ({ children, hotkey, target, onRouterLinkClick }) => {
const intl = useIntl();
const id = useId();
return (
<>
<a href={`#${target}`} aria-describedby={id} onClick={onRouterLinkClick}>
{children}
</a>
<span id={id} className={classes.hotkeyHint}>
{intl.formatMessage(
{
id: 'skip_links.hotkey',
defaultMessage: '<span>Hotkey</span> {hotkey}',
},
{
hotkey,
span: (text) => <span className='sr-only'>{text}</span>,
},
)}
</span>
</>
);
};
@@ -0,0 +1,64 @@
.list {
position: fixed;
z-index: 100;
margin: 10px;
padding: 10px 16px;
border-radius: 10px;
font-size: 15px;
color: var(--color-text-primary);
background: var(--color-bg-primary);
box-shadow: var(--dropdown-shadow);
/* Hide visually when not focused */
&:not(:focus-within) {
width: 1px;
height: 1px;
margin: 0;
padding: 0;
clip-path: inset(50%);
overflow: hidden;
}
}
.listItem {
display: flex;
align-items: center;
gap: 10px;
padding-inline-end: 4px;
border-radius: 4px;
&:not(:first-child) {
margin-top: 2px;
}
&:focus-within {
outline: var(--outline-focus-default);
background: var(--color-bg-brand-softer);
}
:any-link {
display: block;
padding: 8px;
color: inherit;
text-decoration-color: var(--color-text-secondary);
text-underline-offset: 0.2em;
&:focus,
&:focus-visible {
outline: none;
}
}
}
.hotkeyHint {
display: inline-block;
box-sizing: border-box;
min-width: 2.5ch;
margin-inline-start: auto;
padding: 3px 5px;
font-family: 'Courier New', Courier, monospace;
text-align: center;
background: var(--color-bg-primary);
border: 1px solid var(--color-border-primary);
border-radius: 4px;
}
+16 -3
View File
@@ -92,6 +92,7 @@ import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
// Without this it ends up in ~8 very commonly used bundles.
import '../../components/status';
import { areCollectionsEnabled } from '../collections/utils';
import { getNavigationSkipLinkId, SkipLinks } from './components/skip_links';
const messages = defineMessages({
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
@@ -253,9 +254,9 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/lists' component={Lists} content={children} />
{areCollectionsEnabled() &&
[
<WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} />,
<WrappedRoute path='/collections/:id' component={CollectionDetail} content={children} />,
<WrappedRoute path='/collections' component={Collections} content={children} />
<WrappedRoute path={['/collections/new', '/collections/:id/edit']} component={CollectionsEditor} content={children} key='collections-editor' />,
<WrappedRoute path='/collections/:id' component={CollectionDetail} content={children} key='collections-detail' />,
<WrappedRoute path='/collections' component={Collections} content={children} key='collections-list' />
]
}
<Route component={BundleColumnError} />
@@ -556,6 +557,14 @@ class UI extends PureComponent {
handleHotkeyGoToStart = () => {
this.props.history.push('/getting-started');
// Set focus to the navigation after a timeout
// to allow for it to be displayed first
setTimeout(() => {
const navbarSkipTarget = document.querySelector(
`#${getNavigationSkipLinkId()}`,
);
navbarSkipTarget?.focus();
}, 0);
};
handleHotkeyGoToFavourites = () => {
@@ -617,6 +626,10 @@ class UI extends PureComponent {
return (
<Hotkeys global handlers={handlers}>
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}>
<SkipLinks
multiColumn={layout === 'multi-column'}
onFocusGettingStartedColumn={this.handleHotkeyGoToStart}
/>
<SwitchingColumnsArea
identity={this.props.identity}
location={location}
@@ -1,3 +1,8 @@
import {
getColumnSkipLinkId,
getNavigationSkipLinkId,
} from '../components/skip_links';
/**
* Out of a list of elements, return the first one whose top edge
* is inside of the viewport, and return the element and its BoundingClientRect.
@@ -20,9 +25,29 @@ function findFirstVisibleWithRect(
return null;
}
function focusColumnTitle(index: number, multiColumn: boolean) {
if (multiColumn) {
const column = document.querySelector(`.column:nth-child(${index})`);
if (column) {
column
.querySelector<HTMLAnchorElement>(
`#${getColumnSkipLinkId(index - 1)}, #${getNavigationSkipLinkId()}`,
)
?.focus();
}
} else {
const idSelector =
index === 2
? `#${getNavigationSkipLinkId()}`
: `#${getColumnSkipLinkId(1)}`;
document.querySelector<HTMLAnchorElement>(idSelector)?.focus();
}
}
/**
* Move focus to the column of the passed index (1-based).
* Focus is placed on the topmost visible item
* Focus is placed on the topmost visible item, or the column title
*/
export function focusColumn(index = 1) {
// Skip the leftmost drawer in multi-column mode
@@ -35,11 +60,21 @@ export function focusColumn(index = 1) {
`.column:nth-child(${index + indexOffset})`,
);
if (!column) return;
function fallback() {
focusColumnTitle(index + indexOffset, isMultiColumnLayout);
}
if (!column) {
fallback();
return;
}
const container = column.querySelector('.scrollable');
if (!container) return;
if (!container) {
fallback();
return;
}
const focusableItems = Array.from(
container.querySelectorAll<HTMLElement>(
@@ -50,20 +85,23 @@ export function focusColumn(index = 1) {
// Find first item visible in the viewport
const itemToFocus = findFirstVisibleWithRect(focusableItems);
if (itemToFocus) {
const viewportWidth =
window.innerWidth || document.documentElement.clientWidth;
const { item, rect } = itemToFocus;
if (
container.scrollTop > item.offsetTop ||
rect.right > viewportWidth ||
rect.left < 0
) {
itemToFocus.item.scrollIntoView(true);
}
itemToFocus.item.focus();
if (!itemToFocus) {
fallback();
return;
}
const viewportWidth =
window.innerWidth || document.documentElement.clientWidth;
const { item, rect } = itemToFocus;
if (
container.scrollTop > item.offsetTop ||
rect.right > viewportWidth ||
rect.left < 0
) {
itemToFocus.item.scrollIntoView(true);
}
itemToFocus.item.focus();
}
/**