️(frontend) focus skip link on headings and skip grid dropzone

We land keyboard users on page headings and keep the grid dropzone untabbable.
This commit is contained in:
Cyril
2026-03-11 14:12:10 +01:00
parent 99c486571d
commit da79c310ae
10 changed files with 78 additions and 43 deletions
+1
View File
@@ -68,6 +68,7 @@ and this project adheres to
- ⬆️(frontend) upgrade Next.js to v16 #1980
- ♿️(frontend) fix aria-label and landmark on document banner state #1986
- 🌐(i18n) add "new window" translation key for waffle aria-label #1984
- ♿(frontend) focus skip link on headings and skip grid dropzone #1983
### Fixed
@@ -191,7 +191,7 @@ test.describe('Header: Override configuration', () => {
});
test.describe('Header: Skip to Content', () => {
test('it displays skip link on first TAB and focuses main content on click', async ({
test('it displays skip link on first TAB and focuses page heading on click', async ({
page,
}) => {
await page.goto('/');
@@ -206,10 +206,12 @@ test.describe('Header: Skip to Content', () => {
// The skip button should be visible and focused
await expect(skipButton).toBeFocused();
await expect(skipButton).toBeVisible();
// Clicking moves focus to the main content
// Clicking moves focus to the page heading
await skipButton.click();
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
const pageHeading = page.getByRole('heading', {
name: 'All docs',
level: 2,
});
await expect(pageHeading).toBeFocused();
});
});
@@ -18,7 +18,7 @@ test.describe('Left panel desktop', () => {
await expect(page.getByTestId('home-button')).toBeVisible();
});
test('focuses main content after switching the docs filter', async ({
test('focuses page heading after switching the docs filter', async ({
page,
}) => {
await page.goto('/');
@@ -28,8 +28,11 @@ test.describe('Left panel desktop', () => {
await page.keyboard.press('Enter');
await expect(page).toHaveURL(/target=my_docs/);
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
const pageHeading = page.getByRole('heading', {
name: 'My docs',
level: 2,
});
await expect(pageHeading).toBeFocused();
});
test('checks resize handle is present and functional on document page', async ({
@@ -6,7 +6,7 @@ import { css } from 'styled-components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { focusMainContentStart } from '@/layouts/utils';
export const SkipToContent = () => {
const { t } = useTranslation();
@@ -35,10 +35,10 @@ export const SkipToContent = () => {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
if (mainContent) {
mainContent.focus();
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
const focusTarget = focusMainContentStart();
if (focusTarget instanceof HTMLElement) {
focusTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
@@ -51,11 +51,13 @@ export const Heading = ({
editor.setTextCursorPosition(headingId, 'end');
document.querySelector(`[data-id="${headingId}"]`)?.scrollIntoView({
behavior: 'smooth',
inline: 'start',
block: 'start',
});
document
.querySelector<HTMLElement>(`[data-id="${headingId}"]`)
?.scrollIntoView({
behavior: 'smooth',
inline: 'start',
block: 'start',
});
}}
$radius="var(--c--globals--spacings--st)"
$background={
@@ -116,7 +116,9 @@ export const DocsGrid = ({
$padding={{
bottom: 'md',
}}
{...(withUpload ? getRootProps({ className: 'dropzone' }) : {})}
{...(withUpload
? getRootProps({ className: 'dropzone', tabIndex: -1 })
: {})}
>
{withUpload && <input {...getInputProps()} />}
<DocGridTitleBar
@@ -19,7 +19,7 @@ import {
useDuplicateDoc,
useTrans,
} from '@/docs/doc-management';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { focusMainContentStart } from '@/layouts/utils';
import { useFocusStore } from '@/stores';
import { DocMoveModal } from './DocMoveModal';
@@ -55,10 +55,9 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
const { mutate: duplicateDoc } = useDuplicateDoc({
onSuccess: () => {
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
if (mainContent) {
requestAnimationFrame(() => mainContent.focus());
}
requestAnimationFrame(() => {
focusMainContentStart({ preventScroll: true });
});
},
});
@@ -1,7 +1,10 @@
import { useRouter } from 'next/router';
import { useEffect, useRef } from 'react';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import {
focusMainContentStart,
getMainContentFocusTarget,
} from '@/layouts/utils';
export const useRouteChangeCompleteFocus = () => {
const router = useRouter();
@@ -25,27 +28,24 @@ export const useRouteChangeCompleteFocus = () => {
lastCompletedPathRef.current = normalizedUrl;
requestAnimationFrame(() => {
const mainContent =
document.getElementById(MAIN_LAYOUT_ID) ??
document.getElementsByTagName('main')[0];
const focusTarget = getMainContentFocusTarget();
if (!mainContent) {
if (!focusTarget) {
return;
}
const firstHeading = mainContent.querySelector('h1, h2, h3');
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches;
if (isKeyboardNavigationRef.current) {
(mainContent as HTMLElement | null)?.focus({ preventScroll: true });
focusMainContentStart({ preventScroll: true });
isKeyboardNavigationRef.current = false;
}
if (router.pathname === '/docs/[id]') {
return;
}
(firstHeading ?? mainContent)?.scrollIntoView({
focusTarget.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'start',
});
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Header } from '@/features/header';
import { HEADER_HEIGHT } from '@/features/header/conf';
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
@@ -94,7 +93,6 @@ const MainContent = ({
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
return (
@@ -103,7 +101,6 @@ const MainContent = ({
role="main"
aria-label={t('Main content')}
id={MAIN_LAYOUT_ID}
tabIndex={-1}
$align="center"
$flex={1}
$width="100%"
@@ -120,14 +117,6 @@ const MainContent = ({
$css={css`
overflow-y: auto;
overflow-x: clip;
&:focus-visible::after {
content: '';
position: absolute;
inset: 0;
border: 3px solid ${colorsTokens['brand-400']};
pointer-events: none;
z-index: 2001;
}
`}
>
<Skeleton>
@@ -0,0 +1,37 @@
import { MAIN_LAYOUT_ID } from './conf';
export const getMainContentElement = (): HTMLElement | null =>
document.getElementById(MAIN_LAYOUT_ID) ??
document.getElementsByTagName('main')[0] ??
null;
export const getMainContentFocusTarget = (): HTMLElement | null => {
const mainContent = getMainContentElement();
if (!mainContent) {
return null;
}
const firstHeading =
mainContent.querySelector('h1') ?? mainContent.querySelector('h2');
return firstHeading instanceof HTMLElement ? firstHeading : mainContent;
};
export const focusMainContentStart = (
options?: FocusOptions,
): HTMLElement | null => {
const focusTarget = getMainContentFocusTarget();
if (!focusTarget) {
return null;
}
if (!focusTarget.hasAttribute('tabindex')) {
focusTarget.setAttribute('tabindex', '-1');
}
focusTarget.focus(options);
return focusTarget;
};