From 044685306bbafd2e9629c7a412dd3e706b2808e6 Mon Sep 17 00:00:00 2001 From: gonzoblasco Date: Mon, 24 Aug 2026 15:55:04 -0300 Subject: [PATCH] fix(theme-classic): announce page title on route change for screen readers --- .../src/theme/Layout/index.tsx | 2 +- .../src/theme/SkipToContent/index.tsx | 4 +- .../__tests__/skipToContentUtils.test.tsx | 99 +++++++++++++++++++ .../src/utils/skipToContentUtils.tsx | 25 ++++- 4 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 packages/docusaurus-theme-common/src/utils/__tests__/skipToContentUtils.test.tsx diff --git a/packages/docusaurus-theme-classic/src/theme/Layout/index.tsx b/packages/docusaurus-theme-classic/src/theme/Layout/index.tsx index 388f7e912769..a0a587d9ee6d 100644 --- a/packages/docusaurus-theme-classic/src/theme/Layout/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Layout/index.tsx @@ -36,7 +36,7 @@ export default function Layout(props: Props): ReactNode { - + diff --git a/packages/docusaurus-theme-classic/src/theme/SkipToContent/index.tsx b/packages/docusaurus-theme-classic/src/theme/SkipToContent/index.tsx index 2703a8ec36b3..13b04dd8c87d 100644 --- a/packages/docusaurus-theme-classic/src/theme/SkipToContent/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/SkipToContent/index.tsx @@ -9,6 +9,6 @@ import React, {type ReactNode} from 'react'; import {SkipToContentLink} from '@docusaurus/theme-common'; import styles from './styles.module.css'; -export default function SkipToContent(): ReactNode { - return ; +export default function SkipToContent({title}: {title?: string}): ReactNode { + return ; } diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/skipToContentUtils.test.tsx b/packages/docusaurus-theme-common/src/utils/__tests__/skipToContentUtils.test.tsx new file mode 100644 index 000000000000..216029bda163 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/__tests__/skipToContentUtils.test.tsx @@ -0,0 +1,99 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +// @vitest-environment jsdom +import {afterEach, describe, expect, it} from 'vitest'; +import React from 'react'; +import {cleanup, render, screen, waitFor} from '@testing-library/react'; +import {MemoryRouter, Route, Switch, useHistory} from 'react-router-dom'; +import {SkipToContentLink} from '../skipToContentUtils'; +import {TitleFormatterProvider} from '../titleFormatterUtils'; +import {RouteContextProvider} from '../../../../docusaurus/src/client/routeContext'; +import {Context as DocusaurusContext} from '../../../../docusaurus/src/client/docusaurusContext'; + +describe('SkipToContentLink', () => { + afterEach(() => { + cleanup(); + }); + + // A simple formatter that returns the title as-is, so the test does not need + // the full Docusaurus context (siteConfig, route context, etc). + const identityFormatter = ({ + title, + }: { + title: string; + [key: string]: unknown; + }) => title; + + function NavigateButton() { + const history = useHistory(); + return ; + } + + function PageOne() { + return
Page One
; + } + + function PageTwo() { + return
Page Two
; + } + + function renderWithTitle(title: string) { + return render( + + + + + + + + + + + + + + , + ); + } + + it('moves focus to the skip link container on route change', async () => { + renderWithTitle('Page One Title'); + + const skipLink = screen.getByText('Skip to main content'); + const container = skipLink.closest('div'); + expect(container).not.toBeNull(); + + // Click the navigate button to trigger a route change + screen.getByText('go to two').click(); + + // After navigation, the focus should be on the skip link container + await waitFor(() => { + expect(document.activeElement).toBe(container); + }); + }); + + it('announces the page title on the skip link container when navigating', async () => { + renderWithTitle('Page One Title'); + + const skipLink = screen.getByText('Skip to main content'); + const container = skipLink.closest('div'); + expect(container).not.toBeNull(); + + // Click the navigate button to trigger a route change + screen.getByText('go to two').click(); + + // The container's aria-label should be updated to the page title so the + // screen reader announces which page the user landed on. + await waitFor(() => { + expect(container?.getAttribute('aria-label')).toBe('Page One Title'); + }); + }); +}); diff --git a/packages/docusaurus-theme-common/src/utils/skipToContentUtils.tsx b/packages/docusaurus-theme-common/src/utils/skipToContentUtils.tsx index fcc4228a6830..6d1bea34b672 100644 --- a/packages/docusaurus-theme-common/src/utils/skipToContentUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/skipToContentUtils.tsx @@ -14,6 +14,7 @@ import React, { import {useHistory} from '@docusaurus/router'; import {translate} from '@docusaurus/Translate'; import {useLocationChange} from './useLocationChange'; +import {useTitleFormatter} from './titleFormatterUtils'; /** * The id of the element that should become focused on a page @@ -46,7 +47,7 @@ function programmaticFocus(el: HTMLElement) { } /** This hook wires the logic for a skip-to-content link. */ -function useSkipToContent(): { +function useSkipToContent(title?: string): { /** * The ref to the container. On page transition, the container will be focused * so that keyboard navigators can instantly interact with the link and jump @@ -61,6 +62,8 @@ function useSkipToContent(): { } { const containerRef = useRef(null); const {action} = useHistory(); + const titleFormatter = useTitleFormatter(); + const formattedTitle = title ? titleFormatter.format(title) : ''; const onClick = useCallback((e: React.MouseEvent) => { e.preventDefault(); @@ -74,6 +77,12 @@ function useSkipToContent(): { // See https://github.com/facebook/docusaurus/pull/8204#issuecomment-1276547558 useLocationChange(({location}) => { if (containerRef.current && !location.hash && action === 'PUSH') { + // Announce the page title when navigating, so screen reader users know + // which page they landed on. The container's aria-label is restored to + // the skip link label on the next render. + if (formattedTitle) { + containerRef.current.setAttribute('aria-label', formattedTitle); + } programmaticFocus(containerRef.current); } }); @@ -88,11 +97,17 @@ const DefaultSkipToContentLabel = translate({ message: 'Skip to main content', }); -type SkipToContentLinkProps = Omit, 'href' | 'onClick'>; +type SkipToContentLinkProps = Omit, 'href' | 'onClick'> & { + /** + * The page title, announced by screen readers when navigating to a new page. + */ + title?: string; +}; export function SkipToContentLink(props: SkipToContentLinkProps): ReactNode { - const linkLabel = props.children ?? DefaultSkipToContentLabel; - const {containerRef, onClick} = useSkipToContent(); + const {title, ...linkProps} = props; + const linkLabel = linkProps.children ?? DefaultSkipToContentLabel; + const {containerRef, onClick} = useSkipToContent(title); return (
{/* eslint-disable-next-line @docusaurus/no-html-links */}