From 5cfbb5b359a21b0c10788d33e6a82e6f0ba95a81 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Mon, 2 Mar 2026 15:37:33 +0100 Subject: [PATCH] [Glitch] Add "skip to content", "skip to navigation" links Port 816e63d2a553059af4dc88509a327bfc02b05be8 to glitch-soc Signed-off-by: Claire --- .../glitch/components/column_back_button.tsx | 11 ++- .../glitch/components/column_header.tsx | 17 ++-- .../features/navigation_panel/index.tsx | 8 ++ .../features/ui/components/columns_area.jsx | 23 +++-- .../ui/components/skip_links/index.tsx | 84 +++++++++++++++++++ .../skip_links/skip_links.module.scss | 64 ++++++++++++++ .../flavours/glitch/features/ui/index.jsx | 19 ++++- .../glitch/features/ui/util/focusUtils.ts | 70 ++++++++++++---- .../glitch/styles/mastodon/components.scss | 26 +++--- 9 files changed, 280 insertions(+), 42 deletions(-) create mode 100644 app/javascript/flavours/glitch/features/ui/components/skip_links/index.tsx create mode 100644 app/javascript/flavours/glitch/features/ui/components/skip_links/skip_links.module.scss diff --git a/app/javascript/flavours/glitch/components/column_back_button.tsx b/app/javascript/flavours/glitch/components/column_back_button.tsx index 89018c1853..e365db27cb 100644 --- a/app/javascript/flavours/glitch/components/column_back_button.tsx +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/components/icon'; +import { getColumnSkipLinkId } from 'flavours/glitch/features/ui/components/skip_links'; import { ButtonInTabsBar } from 'flavours/glitch/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 && ( = ({ let banner: React.ReactNode; + let linknum = 0; + if (transientSingleColumn) { banner = (
@@ -270,6 +273,7 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({ activeIconComponent={AddIcon} text={intl.formatMessage(messages.compose)} className='button navigation-panel__compose-button' + id={linknum++ === 0 ? getNavigationSkipLinkId() : undefined} /> )} = ({ iconComponent={HomeIcon} activeIconComponent={HomeActiveIcon} text={intl.formatMessage(messages.home)} + id={linknum++ === 0 ? getNavigationSkipLinkId() : undefined} /> )} @@ -290,6 +295,7 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({ icon='explore' iconComponent={TrendingUpIcon} text={intl.formatMessage(messages.explore)} + id={linknum++ === 0 ? getNavigationSkipLinkId() : undefined} /> )} @@ -311,6 +317,7 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({ ? messages.firehose : messages.firehose_singular, )} + id={linknum++ === 0 ? getNavigationSkipLinkId() : undefined} /> )} @@ -380,6 +387,7 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({ icon='ellipsis-h' iconComponent={InfoIcon} text={intl.formatMessage(messages.about)} + id={linknum++ === 0 ? getNavigationSkipLinkId() : undefined} />
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx b/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx index 8be05802d6..b619ad66b2 100644 --- a/app/javascript/flavours/glitch/features/ui/components/columns_area.jsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/ui/components/skip_links/index.tsx b/app/javascript/flavours/glitch/features/ui/components/skip_links/index.tsx new file mode 100644 index 0000000000..686ccfa831 --- /dev/null +++ b/app/javascript/flavours/glitch/features/ui/components/skip_links/index.tsx @@ -0,0 +1,84 @@ +import { useCallback, useId } from 'react'; + +import { useIntl } from 'react-intl'; + +import { useAppSelector } from 'flavours/glitch/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/flavours/glitch/features/ui/components/skip_links/skip_links.module.scss b/app/javascript/flavours/glitch/features/ui/components/skip_links/skip_links.module.scss new file mode 100644 index 0000000000..1d4dc1c3f5 --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/ui/index.jsx b/app/javascript/flavours/glitch/features/ui/index.jsx index 16ece913b0..6668ecb492 100644 --- a/app/javascript/flavours/glitch/features/ui/index.jsx +++ b/app/javascript/flavours/glitch/features/ui/index.jsx @@ -95,6 +95,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.' }, @@ -261,9 +262,9 @@ class SwitchingColumnsArea extends PureComponent { {areCollectionsEnabled() && [ - , - , - + , + , + ] } @@ -602,6 +603,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 = () => { @@ -670,6 +679,10 @@ class UI extends PureComponent { return (
+ {moved && (
( + `#${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/flavours/glitch/styles/mastodon/components.scss b/app/javascript/flavours/glitch/styles/mastodon/components.scss index 3d5a537b5d..0f679c55ee 100644 --- a/app/javascript/flavours/glitch/styles/mastodon/components.scss +++ b/app/javascript/flavours/glitch/styles/mastodon/components.scss @@ -3968,6 +3968,11 @@ a.account__display-name { &:hover { text-decoration: underline; } + + &:focus-visible { + outline: var(--outline-focus-default); + outline-offset: -2px; + } } .column-header__back-button { @@ -4101,15 +4106,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, @@ -4117,9 +4124,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, @@ -4131,17 +4137,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; } }