docusaurus/packages/docusaurus-theme-common/src/utils/skipToContentUtils.tsx
2024-12-06 18:03:04 +01:00

112 lines
3.6 KiB
TypeScript

/**
* 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.
*/
import React, {
useCallback,
useRef,
type ComponentProps,
type ReactNode,
} from 'react';
import {useHistory} from '@docusaurus/router';
import {translate} from '@docusaurus/Translate';
import {useLocationChange} from './useLocationChange';
/**
* The id of the element that should become focused on a page
* that does not have a <main> html tag.
* Focusing the Docusaurus Layout children is a reasonable fallback.
*
* __ prefix allows search crawlers (Algolia/DocSearch) to ignore anchors
* https://github.com/facebook/docusaurus/issues/8883#issuecomment-1516328368
*/
export const SkipToContentFallbackId = '__docusaurus_skipToContent_fallback';
/**
* Returns the skip to content element to focus when the link is clicked.
*/
function getSkipToContentTarget(): HTMLElement | null {
return (
// Try to focus the <main> in priority
// Note: this will only work if JS is enabled
// See https://github.com/facebook/docusaurus/issues/6411#issuecomment-1284136069
document.querySelector('main:first-of-type') ??
// Then try to focus the fallback element (usually the Layout children)
document.getElementById(SkipToContentFallbackId)
);
}
function programmaticFocus(el: HTMLElement) {
el.setAttribute('tabindex', '-1');
el.focus();
el.removeAttribute('tabindex');
}
/** This hook wires the logic for a skip-to-content link. */
function useSkipToContent(): {
/**
* 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
* to content.
*/
containerRef: React.RefObject<HTMLDivElement>;
/**
* Callback fired when the skip to content link has been clicked.
* It will programmatically focus the main content.
*/
onClick: (e: React.MouseEvent<HTMLAnchorElement>) => void;
} {
const containerRef = useRef<HTMLDivElement>(null);
const {action} = useHistory();
const onClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
const targetElement = getSkipToContentTarget();
if (targetElement) {
programmaticFocus(targetElement);
}
}, []);
// "Reset" focus when navigating.
// See https://github.com/facebook/docusaurus/pull/8204#issuecomment-1276547558
useLocationChange(({location}) => {
if (containerRef.current && !location.hash && action === 'PUSH') {
programmaticFocus(containerRef.current);
}
});
return {containerRef, onClick};
}
const DefaultSkipToContentLabel = translate({
id: 'theme.common.skipToMainContent',
description:
'The skip to content label used for accessibility, allowing to rapidly navigate to main content with keyboard tab/enter navigation',
message: 'Skip to main content',
});
type SkipToContentLinkProps = Omit<ComponentProps<'a'>, 'href' | 'onClick'>;
export function SkipToContentLink(props: SkipToContentLinkProps): ReactNode {
const linkLabel = props.children ?? DefaultSkipToContentLabel;
const {containerRef, onClick} = useSkipToContent();
return (
<div
ref={containerRef}
role="region"
aria-label={DefaultSkipToContentLabel}>
{/* eslint-disable-next-line @docusaurus/no-html-links */}
<a
{...props}
// 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}`}
onClick={onClick}>
{linkLabel}
</a>
</div>
);
}