From 816e63d2a553059af4dc88509a327bfc02b05be8 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Mon, 2 Mar 2026 15:37:33 +0100 Subject: [PATCH] Add "skip to content", "skip to navigation" links (#38006) --- .../components/column_back_button.tsx | 11 ++- .../mastodon/components/column_header.tsx | 17 ++-- .../features/navigation_panel/index.tsx | 7 +- .../features/ui/components/columns_area.jsx | 23 +++-- .../ui/components/skip_links/index.tsx | 84 +++++++++++++++++++ .../skip_links/skip_links.module.scss | 64 ++++++++++++++ app/javascript/mastodon/features/ui/index.jsx | 19 ++++- .../mastodon/features/ui/util/focusUtils.ts | 70 ++++++++++++---- app/javascript/mastodon/locales/en.json | 3 + .../styles/mastodon/components.scss | 26 +++--- 10 files changed, 281 insertions(+), 43 deletions(-) create mode 100644 app/javascript/mastodon/features/ui/components/skip_links/index.tsx create mode 100644 app/javascript/mastodon/features/ui/components/skip_links/skip_links.module.scss diff --git a/app/javascript/mastodon/components/column_back_button.tsx b/app/javascript/mastodon/components/column_back_button.tsx index 8012ba7df6..bb6939e24c 100644 --- a/app/javascript/mastodon/components/column_back_button.tsx +++ b/app/javascript/mastodon/components/column_back_button.tsx @@ -4,8 +4,11 @@ import { FormattedMessage } from 'react-intl'; import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react'; import { Icon } from 'mastodon/components/icon'; +import { getColumnSkipLinkId } from 'mastodon/features/ui/components/skip_links'; import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context'; +import { useColumnIndexContext } from '../features/ui/components/columns_area'; + import { useAppHistory } from './router'; type OnClickCallback = () => void; @@ -28,9 +31,15 @@ export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({ onClick, }) => { const handleClick = useHandleClick(onClick); + const columnIndex = useColumnIndexContext(); const component = ( - @@ -221,7 +226,7 @@ export const ColumnHeader: React.FC = ({ !pinned && ((multiColumn && history.location.state?.fromMastodon) || showBackButton) ) { - backButton = ; + backButton = ; } const collapsedContent = [extraContent]; @@ -260,6 +265,7 @@ export const ColumnHeader: React.FC = ({ const hasIcon = icon && iconComponent; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const hasTitle = (hasIcon || backButton) && title; + const columnIndex = useColumnIndexContext(); const component = (
@@ -272,6 +278,7 @@ export const ColumnHeader: React.FC = ({ onClick={handleTitleClick} className='column-header__title' type='button' + id={getColumnSkipLinkId(columnIndex)} > {!backButton && hasIcon && ( = ({ return (
- +
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.jsx b/app/javascript/mastodon/features/ui/components/columns_area.jsx index 753f7e9ac3..5609a52b31 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.jsx +++ b/app/javascript/mastodon/features/ui/components/columns_area.jsx @@ -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
; }; +// 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 (
- {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 ( - - {SpecificComponent => } - + + + {SpecificComponent => } + + ); })} - {Children.map(children, child => cloneElement(child, { multiColumn: true }))} + + {Children.map(children, child => cloneElement(child, { multiColumn: true }))} +
); } diff --git a/app/javascript/mastodon/features/ui/components/skip_links/index.tsx b/app/javascript/mastodon/features/ui/components/skip_links/index.tsx new file mode 100644 index 0000000000..7ecff95287 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/skip_links/index.tsx @@ -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; + return (settings.get('columns') as Immutable.Map).size; + }); + + const focusMultiColumnNavbar = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + onFocusGettingStartedColumn(); + }, + [onFocusGettingStartedColumn], + ); + + return ( +
    +
  • + + {intl.formatMessage({ + id: 'skip_links.skip_to_content', + defaultMessage: 'Skip to main content', + })} + +
  • +
  • + + {intl.formatMessage({ + id: 'skip_links.skip_to_navigation', + defaultMessage: 'Skip to main navigation', + })} + +
  • +
+ ); +}; + +const SkipLink: React.FC<{ + children: string; + target: string; + onRouterLinkClick?: React.MouseEventHandler; + hotkey: string; +}> = ({ children, hotkey, target, onRouterLinkClick }) => { + const intl = useIntl(); + const id = useId(); + return ( + <> + + {children} + + + {intl.formatMessage( + { + id: 'skip_links.hotkey', + defaultMessage: 'Hotkey {hotkey}', + }, + { + hotkey, + span: (text) => {text}, + }, + )} + + + ); +}; diff --git a/app/javascript/mastodon/features/ui/components/skip_links/skip_links.module.scss b/app/javascript/mastodon/features/ui/components/skip_links/skip_links.module.scss new file mode 100644 index 0000000000..1d4dc1c3f5 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/skip_links/skip_links.module.scss @@ -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; +} diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index d40891ef62..b46f61745e 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -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 { {areCollectionsEnabled() && [ - , - , - + , + , + ] } @@ -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 (
+ ( + `#${getColumnSkipLinkId(index - 1)}, #${getNavigationSkipLinkId()}`, + ) + ?.focus(); + } + } else { + const idSelector = + index === 2 + ? `#${getNavigationSkipLinkId()}` + : `#${getColumnSkipLinkId(1)}`; + + document.querySelector(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( @@ -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(); } /** diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index e7a346a38f..e5d62e9436 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -1072,6 +1072,9 @@ "sign_in_banner.mastodon_is": "Mastodon is the best way to keep up with what's happening.", "sign_in_banner.sign_in": "Login", "sign_in_banner.sso_redirect": "Login or Register", + "skip_links.hotkey": "Hotkey {hotkey}", + "skip_links.skip_to_content": "Skip to main content", + "skip_links.skip_to_navigation": "Skip to main navigation", "status.admin_account": "Open moderation interface for @{name}", "status.admin_domain": "Open moderation interface for {domain}", "status.admin_status": "Open this post in the moderation interface", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 1e29fcb2b3..7697d6c273 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3903,6 +3903,11 @@ a.account__display-name { &:hover { text-decoration: underline; } + + &:focus-visible { + outline: var(--outline-focus-default); + outline-offset: -2px; + } } .column-header__back-button { @@ -4036,15 +4041,17 @@ a.account__display-name { } .column-link { + box-sizing: border-box; display: flex; align-items: center; gap: 8px; width: 100%; - padding: 12px; + padding: 10px; + padding-inline-start: 14px; + overflow: hidden; font-size: 16px; font-weight: 400; text-decoration: none; - overflow: hidden; white-space: nowrap; color: color-mix( in oklab, @@ -4052,9 +4059,8 @@ a.account__display-name { var(--color-text-secondary) ); background: transparent; - border: 0; - border-left: 4px solid transparent; - box-sizing: border-box; + border: 2px solid transparent; + border-radius: 4px; &:hover, &:active, @@ -4066,17 +4072,15 @@ a.account__display-name { color: var(--color-text-brand); } - &:focus { - outline: 0; - } - &:focus-visible { + outline: none; border-color: var(--color-text-brand); - border-radius: 0; + background: var(--color-bg-brand-softer); } &--logo { - padding: 10px; + padding: 8px; + padding-inline-start: 12px; } }