Skip to content

Commit 968b033

Browse files
committed
♿(frontend) focus skip link on headings and skip grid dropzone
We land keyboard users on page headings and keep the grid dropzone untabbable.
1 parent 36c6762 commit 968b033

File tree

8 files changed

+70
-40
lines changed

8 files changed

+70
-40
lines changed

src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ test.describe('Header: Override configuration', () => {
190190
});
191191

192192
test.describe('Header: Skip to Content', () => {
193-
test('it displays skip link on first TAB and focuses main content on click', async ({
193+
test('it displays skip link on first TAB and focuses page heading on click', async ({
194194
page,
195195
}) => {
196196
await page.goto('/');
@@ -205,10 +205,12 @@ test.describe('Header: Skip to Content', () => {
205205
// The skip button should be visible and focused
206206
await expect(skipButton).toBeFocused();
207207
await expect(skipButton).toBeVisible();
208-
209-
// Clicking moves focus to the main content
208+
// Clicking moves focus to the page heading
210209
await skipButton.click();
211-
const mainContent = page.locator('main#mainContent');
212-
await expect(mainContent).toBeFocused();
210+
const pageHeading = page.getByRole('heading', {
211+
name: 'All docs',
212+
level: 1,
213+
});
214+
await expect(pageHeading).toBeFocused();
213215
});
214216
});

src/frontend/apps/impress/src/components/SkipToContent.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { css } from 'styled-components';
66

77
import { Box } from '@/components';
88
import { useCunninghamTheme } from '@/cunningham';
9-
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
9+
import { focusMainContentStart } from '@/layouts/conf';
1010

1111
export const SkipToContent = () => {
1212
const { t } = useTranslation();
@@ -35,10 +35,10 @@ export const SkipToContent = () => {
3535

3636
const handleClick = (e: React.MouseEvent) => {
3737
e.preventDefault();
38-
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
39-
if (mainContent) {
40-
mainContent.focus();
41-
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
38+
const focusTarget = focusMainContentStart();
39+
40+
if (focusTarget instanceof HTMLElement) {
41+
focusTarget.scrollIntoView({ behavior: 'smooth', block: 'start' });
4242
}
4343
};
4444

src/frontend/apps/impress/src/features/docs/doc-table-content/components/Heading.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,13 @@ export const Heading = ({
5151

5252
editor.setTextCursorPosition(headingId, 'end');
5353

54-
document.querySelector(`[data-id="${headingId}"]`)?.scrollIntoView({
55-
behavior: 'smooth',
56-
inline: 'start',
57-
block: 'start',
58-
});
54+
document
55+
.querySelector<HTMLElement>(`[data-id="${headingId}"]`)
56+
?.scrollIntoView({
57+
behavior: 'smooth',
58+
inline: 'start',
59+
block: 'start',
60+
});
5961
}}
6062
$radius="var(--c--globals--spacings--st)"
6163
$background={

src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ export const DocsGrid = ({
116116
$padding={{
117117
bottom: 'md',
118118
}}
119-
{...(withUpload ? getRootProps({ className: 'dropzone' }) : {})}
119+
{...(withUpload
120+
? getRootProps({ className: 'dropzone', tabIndex: -1 })
121+
: {})}
120122
>
121123
{withUpload && <input {...getInputProps()} />}
122124
<DocGridTitleBar

src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
useTrans,
2121
} from '@/docs/doc-management';
2222
import { DocShareModal } from '@/docs/doc-share';
23-
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
23+
import { focusMainContentStart } from '@/layouts/conf';
2424
import { useFocusStore } from '@/stores';
2525

2626
import { DocMoveModal } from './DocMoveModal';
@@ -40,10 +40,9 @@ export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
4040

4141
const { mutate: duplicateDoc } = useDuplicateDoc({
4242
onSuccess: () => {
43-
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
44-
if (mainContent) {
45-
requestAnimationFrame(() => mainContent.focus());
46-
}
43+
requestAnimationFrame(() => {
44+
focusMainContentStart({ preventScroll: true });
45+
});
4746
},
4847
});
4948

src/frontend/apps/impress/src/hooks/useRouteChangeCompleteFocus.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { useRouter } from 'next/router';
22
import { useEffect, useRef } from 'react';
33

4-
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
4+
import {
5+
focusMainContentStart,
6+
getMainContentFocusTarget,
7+
} from '@/layouts/conf';
58

69
export const useRouteChangeCompleteFocus = () => {
710
const router = useRouter();
@@ -25,27 +28,24 @@ export const useRouteChangeCompleteFocus = () => {
2528
lastCompletedPathRef.current = normalizedUrl;
2629

2730
requestAnimationFrame(() => {
28-
const mainContent =
29-
document.getElementById(MAIN_LAYOUT_ID) ??
30-
document.getElementsByTagName('main')[0];
31+
const focusTarget = getMainContentFocusTarget();
3132

32-
if (!mainContent) {
33+
if (!focusTarget) {
3334
return;
3435
}
3536

36-
const firstHeading = mainContent.querySelector('h1, h2, h3');
3737
const prefersReducedMotion = window.matchMedia(
3838
'(prefers-reduced-motion: reduce)',
3939
).matches;
4040

4141
if (isKeyboardNavigationRef.current) {
42-
(mainContent as HTMLElement | null)?.focus({ preventScroll: true });
42+
focusMainContentStart({ preventScroll: true });
4343
isKeyboardNavigationRef.current = false;
4444
}
4545
if (router.pathname === '/docs/[id]') {
4646
return;
4747
}
48-
(firstHeading ?? mainContent)?.scrollIntoView({
48+
focusTarget.scrollIntoView({
4949
behavior: prefersReducedMotion ? 'auto' : 'smooth',
5050
block: 'start',
5151
});

src/frontend/apps/impress/src/layouts/MainLayout.tsx

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
33
import { css } from 'styled-components';
44

55
import { Box } from '@/components';
6-
import { useCunninghamTheme } from '@/cunningham';
76
import { Header } from '@/features/header';
87
import { HEADER_HEIGHT } from '@/features/header/conf';
98
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
@@ -94,7 +93,6 @@ const MainContent = ({
9493
const { isDesktop } = useResponsiveStore();
9594

9695
const { t } = useTranslation();
97-
const { colorsTokens } = useCunninghamTheme();
9896
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
9997

10098
return (
@@ -103,7 +101,6 @@ const MainContent = ({
103101
role="main"
104102
aria-label={t('Main content')}
105103
id={MAIN_LAYOUT_ID}
106-
tabIndex={-1}
107104
$align="center"
108105
$flex={1}
109106
$width="100%"
@@ -120,14 +117,6 @@ const MainContent = ({
120117
$css={css`
121118
overflow-y: auto;
122119
overflow-x: clip;
123-
&:focus-visible::after {
124-
content: '';
125-
position: absolute;
126-
inset: 0;
127-
border: 3px solid ${colorsTokens['brand-400']};
128-
pointer-events: none;
129-
z-index: 2001;
130-
}
131120
`}
132121
>
133122
<Skeleton>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,37 @@
11
export const MAIN_LAYOUT_ID = `mainContent`;
2+
3+
export const getMainContentElement = (): HTMLElement | null =>
4+
document.getElementById(MAIN_LAYOUT_ID) ??
5+
document.getElementsByTagName('main')[0] ??
6+
null;
7+
8+
export const getMainContentFocusTarget = (): HTMLElement | null => {
9+
const mainContent = getMainContentElement();
10+
11+
if (!mainContent) {
12+
return null;
13+
}
14+
15+
const firstHeading =
16+
mainContent.querySelector('h1') ?? mainContent.querySelector('h2');
17+
18+
return firstHeading instanceof HTMLElement ? firstHeading : mainContent;
19+
};
20+
21+
export const focusMainContentStart = (
22+
options?: FocusOptions,
23+
): HTMLElement | null => {
24+
const focusTarget = getMainContentFocusTarget();
25+
26+
if (!focusTarget) {
27+
return null;
28+
}
29+
30+
if (!focusTarget.hasAttribute('tabindex')) {
31+
focusTarget.setAttribute('tabindex', '-1');
32+
}
33+
34+
focusTarget.focus(options);
35+
36+
return focusTarget;
37+
};

0 commit comments

Comments
 (0)