Skip to content

Commit be66e89

Browse files
committed
✨(a11y) add skip to content button for keyboard accessibility
add SkipToContent component to meet RGAA skiplink requirement Signed-off-by: Cyril <c.gromoff@gmail.com> ✅(frontend) add e2e test for skiplink and fix broken accessibility test ensures skiplink behavior is tested and stabilizes a failing accessibility test Signed-off-by: Cyril <c.gromoff@gmail.com>
1 parent 9aeedd1 commit be66e89

File tree

9 files changed

+215
-68
lines changed

9 files changed

+215
-68
lines changed

CHANGELOG.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ and this project adheres to
1010

1111
- ✨ Add comments feature to the editor #1330
1212
- ✨(backend) Comments on text editor #1330
13+
- ♿(frontend) improve accessibility:
14+
- ♿(frontend) add skip to content button for keyboard accessibility #1624
15+
- 🐛(frontend) fix toolbar not activated when reader #1640
16+
- 🐛(frontend) preserve left panel width on window resize #1588
17+
- 🐛(frontend) prevent duplicate as first character in title #1595
1318

1419
### Changed
1520

@@ -22,9 +27,7 @@ and this project adheres to
2227
- ♿(frontend) improve accessibility:
2328
- ♿(frontend) improve share modal button accessibility #1626
2429
- ♿(frontend) improve screen reader support in DocShare modal #1628
25-
- 🐛(frontend) fix toolbar not activated when reader #1640
26-
- 🐛(frontend) preserve left panel width on window resize #1588
27-
- 🐛(frontend) prevent duplicate as first character in title #1595
30+
- ♿(frontend) add skip to content button for keyboard accessibility #1624
2831

2932
## [3.10.0] - 2025-11-18
3033

@@ -45,6 +48,9 @@ and this project adheres to
4548
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
4649
- ♿(frontend) improve accessibility and styling of summary table #1528
4750
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
51+
- 🐛(frontend) preserve @ character when esc is pressed after typing it #1512
52+
- 🐛(frontend) make summary button fixed to remain visible during scroll #1581
53+
- 🐛(frontend) fix pdf embed to use full width #1526
4854
- 🐛(frontend) fix alignment of side menu #1597
4955
- 🐛(frontend) fix fallback translations with Trans #1620
5056
- 🐛(export) fix image overflow by limiting width to 600px during export #1525

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,27 @@ test.describe('Header: Override configuration', () => {
177177
await expect(logoImage).toHaveAttribute('alt', '');
178178
});
179179
});
180+
181+
test.describe('Header: Skip to Content', () => {
182+
test('it displays skip link on first TAB and focuses main content on click', async ({
183+
page,
184+
}) => {
185+
await page.goto('/');
186+
187+
// Wait for skip link to be mounted (client-side only component)
188+
const skipLink = page.getByRole('link', { name: 'Go to content' });
189+
await skipLink.waitFor({ state: 'attached' });
190+
191+
// First TAB shows the skip link
192+
await page.keyboard.press('Tab');
193+
194+
// The skip link should be visible and focused
195+
await expect(skipLink).toBeFocused();
196+
await expect(skipLink).toBeVisible();
197+
198+
// Clicking moves focus to the main content
199+
await skipLink.click();
200+
const mainContent = page.locator('main#mainContent');
201+
await expect(mainContent).toBeFocused();
202+
});
203+
});

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ test.describe('Language', () => {
6666
await page.keyboard.press('Tab');
6767
await page.keyboard.press('Tab');
6868
await page.keyboard.press('Tab');
69+
await page.keyboard.press('Tab');
6970

7071
await page.keyboard.press('Enter');
7172

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Button } from '@openfun/cunningham-react';
2+
import { useRouter } from 'next/router';
3+
import { useEffect, useState } from 'react';
4+
import { useTranslation } from 'react-i18next';
5+
import { css } from 'styled-components';
6+
7+
import { Box } from '@/components';
8+
import { useCunninghamTheme } from '@/cunningham';
9+
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
10+
11+
export const SkipToContent = () => {
12+
const { t } = useTranslation();
13+
const router = useRouter();
14+
const { spacingsTokens } = useCunninghamTheme();
15+
const [isVisible, setIsVisible] = useState(false);
16+
17+
// Reset focus after route change so first TAB goes to skip link
18+
useEffect(() => {
19+
const handleRouteChange = () => {
20+
(document.activeElement as HTMLElement)?.blur();
21+
22+
document.body.setAttribute('tabindex', '-1');
23+
document.body.focus({ preventScroll: true });
24+
25+
setTimeout(() => {
26+
document.body.removeAttribute('tabindex');
27+
}, 100);
28+
};
29+
30+
router.events.on('routeChangeComplete', handleRouteChange);
31+
return () => {
32+
router.events.off('routeChangeComplete', handleRouteChange);
33+
};
34+
}, [router.events]);
35+
36+
const handleClick = (e: React.MouseEvent) => {
37+
e.preventDefault();
38+
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
39+
if (mainContent) {
40+
mainContent.focus();
41+
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
42+
}
43+
};
44+
45+
return (
46+
<Box
47+
$css={css`
48+
.c__button--brand--primary.--docs--skip-to-content:focus-visible {
49+
box-shadow:
50+
0 0 0 1px var(--c--globals--colors--white-000),
51+
0 0 0 4px var(--c--contextuals--border--semantic--brand--primary);
52+
border-radius: var(--c--globals--spacings--st);
53+
}
54+
`}
55+
>
56+
<Button
57+
onClick={handleClick}
58+
type="button"
59+
color="brand"
60+
className="--docs--skip-to-content"
61+
onFocus={() => setIsVisible(true)}
62+
onBlur={() => setIsVisible(false)}
63+
style={{
64+
opacity: isVisible ? 1 : 0,
65+
pointerEvents: isVisible ? 'auto' : 'none',
66+
position: 'fixed',
67+
top: spacingsTokens['2xs'],
68+
// padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px
69+
left: `calc(${spacingsTokens['base']} + 32px + ${spacingsTokens['3xs']} + 70px + 12px)`,
70+
zIndex: 9999,
71+
whiteSpace: 'nowrap',
72+
}}
73+
>
74+
{t('Go to content')}
75+
</Button>
76+
</Box>
77+
);
78+
};

src/frontend/apps/impress/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ export * from './Loading';
1111
export * from './modal';
1212
export * from './Overlayer';
1313
export * from './separators';
14+
export * from './SkipToContent';
1415
export * from './Text';
1516
export * from './TextErrors';

src/frontend/apps/impress/src/features/header/components/Header.tsx

Lines changed: 68 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Image from 'next/image';
22
import { useTranslation } from 'react-i18next';
33
import { css } from 'styled-components';
44

5-
import { Box, StyledLink } from '@/components/';
5+
import { Box, SkipToContent, StyledLink } from '@/components/';
66
import { useConfig } from '@/core/config';
77
import { useCunninghamTheme } from '@/cunningham';
88
import { ButtonLogin } from '@/features/auth';
@@ -25,72 +25,76 @@ export const Header = () => {
2525
config?.theme_customization?.header?.icon || componentTokens.icon;
2626

2727
return (
28-
<Box
29-
as="header"
30-
role="banner"
31-
$css={css`
32-
position: fixed;
33-
top: 0;
34-
left: 0;
35-
right: 0;
36-
z-index: 1000;
37-
flex-direction: row;
38-
align-items: center;
39-
justify-content: space-between;
40-
height: ${HEADER_HEIGHT}px;
41-
padding: 0 ${spacingsTokens['base']};
42-
background-color: var(--c--contextuals--background--surface--primary);
43-
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
44-
`}
45-
className="--docs--header"
46-
>
47-
{!isDesktop && <ButtonTogglePanel />}
48-
<StyledLink
49-
href="/"
50-
data-testid="header-logo-link"
51-
aria-label={t('Back to homepage')}
28+
<>
29+
<SkipToContent />
30+
<Box
31+
as="header"
32+
role="banner"
5233
$css={css`
53-
outline: none;
54-
&:focus-visible {
55-
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400) !important;
56-
border-radius: var(--c--globals--spacings--st);
57-
}
34+
position: fixed;
35+
top: 0;
36+
left: 0;
37+
right: 0;
38+
z-index: 1000;
39+
flex-direction: row;
40+
align-items: center;
41+
justify-content: space-between;
42+
height: ${HEADER_HEIGHT}px;
43+
padding: 0 ${spacingsTokens['base']};
44+
background-color: var(--c--contextuals--background--surface--primary);
45+
border-bottom: 1px solid
46+
var(--c--contextuals--border--surface--primary);
5847
`}
48+
className="--docs--header"
5949
>
60-
<Box
61-
$align="center"
62-
$gap={spacingsTokens['3xs']}
63-
$direction="row"
64-
$position="relative"
65-
$height="fit-content"
66-
$margin={{ top: 'auto' }}
50+
{!isDesktop && <ButtonTogglePanel />}
51+
<StyledLink
52+
href="/"
53+
data-testid="header-logo-link"
54+
aria-label={t('Back to homepage')}
55+
$css={css`
56+
outline: none;
57+
&:focus-visible {
58+
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400) !important;
59+
border-radius: var(--c--globals--spacings--st);
60+
}
61+
`}
6762
>
68-
<Image
69-
data-testid="header-icon-docs"
70-
src={icon.src || ''}
71-
alt=""
72-
width={0}
73-
height={0}
74-
style={{
75-
width: icon.width,
76-
height: icon.height,
77-
}}
78-
priority
79-
/>
80-
<Title headingLevel="h1" aria-hidden="true" />
81-
</Box>
82-
</StyledLink>
83-
{!isDesktop ? (
84-
<Box $direction="row" $gap={spacingsTokens['sm']}>
85-
<LaGaufre />
86-
</Box>
87-
) : (
88-
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
89-
<ButtonLogin />
90-
<LanguagePicker />
91-
<LaGaufre />
92-
</Box>
93-
)}
94-
</Box>
63+
<Box
64+
$align="center"
65+
$gap={spacingsTokens['3xs']}
66+
$direction="row"
67+
$position="relative"
68+
$height="fit-content"
69+
$margin={{ top: 'auto' }}
70+
>
71+
<Image
72+
data-testid="header-icon-docs"
73+
src={icon.src || ''}
74+
alt=""
75+
width={0}
76+
height={0}
77+
style={{
78+
width: icon.width,
79+
height: icon.height,
80+
}}
81+
priority
82+
/>
83+
<Title headingLevel="h1" aria-hidden="true" />
84+
</Box>
85+
</StyledLink>
86+
{!isDesktop ? (
87+
<Box $direction="row" $gap={spacingsTokens['sm']}>
88+
<LaGaufre />
89+
</Box>
90+
) : (
91+
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
92+
<ButtonLogin />
93+
<LanguagePicker />
94+
<LaGaufre />
95+
</Box>
96+
)}
97+
</Box>
98+
</>
9599
);
96100
};

src/frontend/apps/impress/src/features/home/components/HomeContent.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { css } from 'styled-components';
55
import { Box, Icon, Text } from '@/components';
66
import { Footer } from '@/features/footer';
77
import { LeftPanel } from '@/features/left-panel';
8+
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
89
import { useResponsiveStore } from '@/stores';
910

1011
import SC1ResponsiveEn from '../assets/SC1-responsive-en.png';
@@ -34,8 +35,19 @@ export function HomeContent() {
3435
<Box
3536
as="main"
3637
role="main"
38+
id={MAIN_LAYOUT_ID}
39+
tabIndex={-1}
3740
className="--docs--home-content"
3841
aria-label={t('Main content')}
42+
$css={css`
43+
&:focus {
44+
outline: 3px solid var(--c--theme--colors--primary-600);
45+
outline-offset: -3px;
46+
}
47+
&:focus:not(:focus-visible) {
48+
outline: none;
49+
}
50+
`}
3951
>
4052
<HomeHeader />
4153
{isSmallMobile && (

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

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

55
import { Box } from '@/components';
6+
import { useCunninghamTheme } from '@/cunningham';
67
import { Header } from '@/features/header';
78
import { HEADER_HEIGHT } from '@/features/header/conf';
89
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
@@ -53,6 +54,7 @@ export function MainLayoutContent({
5354
}: PropsWithChildren<MainLayoutContentProps>) {
5455
const { isDesktop } = useResponsiveStore();
5556
const { t } = useTranslation();
57+
const { colorsTokens } = useCunninghamTheme();
5658
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
5759

5860
const mainContent = (
@@ -61,6 +63,7 @@ export function MainLayoutContent({
6163
role="main"
6264
aria-label={t('Main content')}
6365
id={MAIN_LAYOUT_ID}
66+
tabIndex={-1}
6467
$align="center"
6568
$flex={1}
6669
$width="100%"
@@ -77,6 +80,10 @@ export function MainLayoutContent({
7780
$css={css`
7881
overflow-y: auto;
7982
overflow-x: clip;
83+
&:focus {
84+
outline: 3px solid ${colorsTokens['brand-400']};
85+
outline-offset: -3px;
86+
}
8087
`}
8188
>
8289
<Skeleton>

0 commit comments

Comments
 (0)