Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default function Layout(props: Props): ReactNode {
<LayoutProvider>
<PageMetadata title={title} description={description} />

<SkipToContent />
<SkipToContent title={title} />

<AnnouncementBar />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SkipToContentLink className={styles.skipToContent} />;
export default function SkipToContent({title}: {title?: string}): ReactNode {
return <SkipToContentLink className={styles.skipToContent} title={title} />;
}
Original file line number Diff line number Diff line change
@@ -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 <button onClick={() => history.push('/two')}>go to two</button>;
}

function PageOne() {
return <div>Page One</div>;
}

function PageTwo() {
return <div>Page Two</div>;
}

function renderWithTitle(title: string) {
return render(
<DocusaurusContext.Provider
value={{
siteConfig: {title: 'Docusaurus', titleDelimiter: '·'},
}}>
<RouteContextProvider
value={{plugin: {id: 'test', name: 'test'}, data: {}}}>
<TitleFormatterProvider formatter={identityFormatter}>
<MemoryRouter initialEntries={['/one']}>
<SkipToContentLink title={title} />
<Switch>
<Route path="/one" component={PageOne} />
<Route path="/two" component={PageTwo} />
</Switch>
<NavigateButton />
</MemoryRouter>
</TitleFormatterProvider>
</RouteContextProvider>
</DocusaurusContext.Provider>,
);
}

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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -61,6 +62,8 @@ function useSkipToContent(): {
} {
const containerRef = useRef<HTMLDivElement>(null);
const {action} = useHistory();
const titleFormatter = useTitleFormatter();
const formattedTitle = title ? titleFormatter.format(title) : '';

const onClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
Expand All @@ -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);
}
});
Expand All @@ -88,19 +97,25 @@ const DefaultSkipToContentLabel = translate({
message: 'Skip to main content',
});

type SkipToContentLinkProps = Omit<ComponentProps<'a'>, 'href' | 'onClick'>;
type SkipToContentLinkProps = Omit<ComponentProps<'a'>, '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 (
<div
ref={containerRef}
role="region"
aria-label={DefaultSkipToContentLabel}>
{/* eslint-disable-next-line @docusaurus/no-html-links */}
<a
{...props}
{...linkProps}
// Note this is a fallback href in case JS is disabled
// It has limitations, see https://github.com/facebook/docusaurus/issues/6411#issuecomment-1284136069
href={`#${SkipToContentFallbackId}`}
Expand Down