(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>
This commit is contained in:
Cyril
2025-11-18 11:09:48 +01:00
parent 77df9783b7
commit 1e37007be9
9 changed files with 222 additions and 71 deletions

View File

@@ -6,6 +6,11 @@ and this project adheres to
## [Unreleased]
### Changed
- ♿(frontend) improve accessibility:
- ♿(frontend) add skip to content button for keyboard accessibility #1624
## [4.0.0] - 2025-12-01
### Added
@@ -13,6 +18,14 @@ and this project adheres to
- ✨ Add comments feature to the editor #1330
- ✨(backend) Comments on text editor #1330
- ✨(frontend) link to create new doc #1574
- ♿(frontend) improve accessibility:
- ♿(frontend) add skip to content button for keyboard accessibility #1624
### Fixed
- 🐛(frontend) fix toolbar not activated when reader #1640
- 🐛(frontend) preserve left panel width on window resize #1588
- 🐛(frontend) prevent duplicate as first character in title #1595
### Changed
@@ -23,12 +36,6 @@ and this project adheres to
- ♿(frontend) improve share modal button accessibility #1626
- ♿(frontend) improve screen reader support in DocShare modal #1628
### Fixed
- 🐛(frontend) fix toolbar not activated when reader #1640
- 🐛(frontend) preserve left panel width on window resize #1588
- 🐛(frontend) prevent duplicate as first character in title #1595
## [3.10.0] - 2025-11-18
### Added
@@ -48,6 +55,9 @@ and this project adheres to
- ♿(frontend) improve ARIA in doc grid and editor for a11y #1519
- ♿(frontend) improve accessibility and styling of summary table #1528
- ♿(frontend) add focus trap and enter key support to remove doc modal #1531
- 🐛(frontend) preserve @ character when esc is pressed after typing it #1512
- 🐛(frontend) make summary button fixed to remain visible during scroll #1581
- 🐛(frontend) fix pdf embed to use full width #1526
- 🐛(frontend) fix alignment of side menu #1597
- 🐛(frontend) fix fallback translations with Trans #1620
- 🐛(export) fix image overflow by limiting width to 600px during export #1525

View File

@@ -177,3 +177,27 @@ test.describe('Header: Override configuration', () => {
await expect(logoImage).toHaveAttribute('alt', '');
});
});
test.describe('Header: Skip to Content', () => {
test('it displays skip link on first TAB and focuses main content on click', async ({
page,
}) => {
await page.goto('/');
// Wait for skip button to be mounted (client-side only component)
const skipButton = page.getByRole('button', { name: 'Go to content' });
await skipButton.waitFor({ state: 'attached' });
// First TAB shows the skip button
await page.keyboard.press('Tab');
// The skip button should be visible and focused
await expect(skipButton).toBeFocused();
await expect(skipButton).toBeVisible();
// Clicking moves focus to the main content
await skipButton.click();
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
});
});

View File

@@ -66,6 +66,7 @@ test.describe('Language', () => {
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Tab');
await page.keyboard.press('Enter');

View File

@@ -0,0 +1,78 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
export const SkipToContent = () => {
const { t } = useTranslation();
const router = useRouter();
const { spacingsTokens } = useCunninghamTheme();
const [isVisible, setIsVisible] = useState(false);
// Reset focus after route change so first TAB goes to skip link
useEffect(() => {
const handleRouteChange = () => {
(document.activeElement as HTMLElement)?.blur();
document.body.setAttribute('tabindex', '-1');
document.body.focus({ preventScroll: true });
setTimeout(() => {
document.body.removeAttribute('tabindex');
}, 100);
};
router.events.on('routeChangeComplete', handleRouteChange);
return () => {
router.events.off('routeChangeComplete', handleRouteChange);
};
}, [router.events]);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
if (mainContent) {
mainContent.focus();
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<Box
$css={css`
.c__button--brand--primary.--docs--skip-to-content:focus-visible {
box-shadow:
0 0 0 1px var(--c--globals--colors--white-000),
0 0 0 4px var(--c--contextuals--border--semantic--brand--primary);
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Button
onClick={handleClick}
type="button"
color="brand"
className="--docs--skip-to-content"
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
style={{
opacity: isVisible ? 1 : 0,
pointerEvents: isVisible ? 'auto' : 'none',
position: 'fixed',
top: spacingsTokens['2xs'],
// padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px
left: `calc(${spacingsTokens['base']} + 32px + ${spacingsTokens['3xs']} + 70px + 12px)`,
zIndex: 9999,
whiteSpace: 'nowrap',
}}
>
{t('Go to content')}
</Button>
</Box>
);
};

View File

@@ -11,5 +11,6 @@ export * from './Loading';
export * from './modal';
export * from './Overlayer';
export * from './separators';
export * from './SkipToContent';
export * from './Text';
export * from './TextErrors';

View File

@@ -2,7 +2,7 @@ import Image from 'next/image';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components/';
import { Box, SkipToContent, StyledLink } from '@/components/';
import { useConfig } from '@/core/config';
import { useCunninghamTheme } from '@/cunningham';
import { ButtonLogin } from '@/features/auth';
@@ -25,72 +25,76 @@ export const Header = () => {
config?.theme_customization?.header?.icon || componentTokens.icon;
return (
<Box
as="header"
role="banner"
$css={css`
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: ${HEADER_HEIGHT}px;
padding: 0 ${spacingsTokens['base']};
background-color: var(--c--contextuals--background--surface--primary);
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
`}
className="--docs--header"
>
{!isDesktop && <ButtonTogglePanel />}
<StyledLink
href="/"
data-testid="header-logo-link"
aria-label={t('Back to homepage')}
<>
<SkipToContent />
<Box
as="header"
role="banner"
$css={css`
outline: none;
&:focus-visible {
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400) !important;
border-radius: var(--c--globals--spacings--st);
}
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: ${HEADER_HEIGHT}px;
padding: 0 ${spacingsTokens['base']};
background-color: var(--c--contextuals--background--surface--primary);
border-bottom: 1px solid
var(--c--contextuals--border--surface--primary);
`}
className="--docs--header"
>
<Box
$align="center"
$gap={spacingsTokens['3xs']}
$direction="row"
$position="relative"
$height="fit-content"
$margin={{ top: 'auto' }}
{!isDesktop && <ButtonTogglePanel />}
<StyledLink
href="/"
data-testid="header-logo-link"
aria-label={t('Back to homepage')}
$css={css`
outline: none;
&:focus-visible {
box-shadow: 0 0 0 2px var(--c--globals--colors--brand-400) !important;
border-radius: var(--c--globals--spacings--st);
}
`}
>
<Image
data-testid="header-icon-docs"
src={icon.src || ''}
alt=""
width={0}
height={0}
style={{
width: icon.width,
height: icon.height,
}}
priority
/>
<Title headingLevel="h1" aria-hidden="true" />
</Box>
</StyledLink>
{!isDesktop ? (
<Box $direction="row" $gap={spacingsTokens['sm']}>
<LaGaufre />
</Box>
) : (
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
</Box>
)}
</Box>
<Box
$align="center"
$gap={spacingsTokens['3xs']}
$direction="row"
$position="relative"
$height="fit-content"
$margin={{ top: 'auto' }}
>
<Image
data-testid="header-icon-docs"
src={icon.src || ''}
alt=""
width={0}
height={0}
style={{
width: icon.width,
height: icon.height,
}}
priority
/>
<Title headingLevel="h1" aria-hidden="true" />
</Box>
</StyledLink>
{!isDesktop ? (
<Box $direction="row" $gap={spacingsTokens['sm']}>
<LaGaufre />
</Box>
) : (
<Box $align="center" $gap={spacingsTokens['sm']} $direction="row">
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
</Box>
)}
</Box>
</>
);
};

View File

@@ -5,6 +5,7 @@ import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { Footer } from '@/features/footer';
import { LeftPanel } from '@/features/left-panel';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { useResponsiveStore } from '@/stores';
import SC1ResponsiveEn from '../assets/SC1-responsive-en.png';
@@ -34,8 +35,19 @@ export function HomeContent() {
<Box
as="main"
role="main"
id={MAIN_LAYOUT_ID}
tabIndex={-1}
className="--docs--home-content"
aria-label={t('Main content')}
$css={css`
&:focus {
outline: 3px solid var(--c--theme--colors--primary-600);
outline-offset: -3px;
}
&:focus:not(:focus-visible) {
outline: none;
}
`}
>
<HomeHeader />
{isSmallMobile && (

View File

@@ -3,6 +3,7 @@ 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';
@@ -53,6 +54,7 @@ export function MainLayoutContent({
}: PropsWithChildren<MainLayoutContentProps>) {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
const mainContent = (
@@ -61,6 +63,7 @@ export function MainLayoutContent({
role="main"
aria-label={t('Main content')}
id={MAIN_LAYOUT_ID}
tabIndex={-1}
$align="center"
$flex={1}
$width="100%"
@@ -77,6 +80,10 @@ export function MainLayoutContent({
$css={css`
overflow-y: auto;
overflow-x: clip;
&:focus {
outline: 3px solid ${colorsTokens['brand-400']};
outline-offset: -3px;
}
`}
>
<Skeleton>

View File

@@ -1,5 +1,6 @@
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { Footer } from '@/features/footer';
@@ -7,6 +8,8 @@ import { HEADER_HEIGHT, Header } from '@/features/header';
import { LeftPanel } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import { MAIN_LAYOUT_ID } from './conf';
interface PageLayoutProps {
withFooter?: boolean;
}
@@ -27,8 +30,19 @@ export function PageLayout({
<Box
as="main"
role="main"
id={MAIN_LAYOUT_ID}
tabIndex={-1}
$width="100%"
$css="flex-grow:1;"
$css={css`
flex-grow: 1;
&:focus {
outline: 3px solid var(--c--theme--colors--primary-600);
outline-offset: -3px;
}
&:focus:not(:focus-visible) {
outline: none;
}
`}
aria-label={t('Main content')}
>
{!isDesktop && <LeftPanel />}