Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ and this project adheres to
- ♿(frontend) improve accessibility:
- ♿(frontend) improve share modal button accessibility #1626

### Added

- ♿(frontend) improve accessibility:
- ♿(frontend) add skip to content button for keyboard accessibility #1624

## [3.10.0] - 2025-11-18

### Added
Expand All @@ -30,6 +35,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
Expand All @@ -46,7 +54,6 @@ and this project adheres to

- 🔥(backend) remove api managing templates


## [3.9.0] - 2025-11-10

### Added
Expand Down
24 changes: 24 additions & 0 deletions src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,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 link to be mounted (client-side only component)
const skipLink = page.getByRole('link', { name: 'Go to content' });
await skipLink.waitFor({ state: 'attached' });

// First TAB shows the skip link
await page.keyboard.press('Tab');

// The skip link should be visible and focused
await expect(skipLink).toBeFocused();
await expect(skipLink).toBeVisible();

// Clicking moves focus to the main content
await skipLink.click();
const mainContent = page.locator('main#mainContent');
await expect(mainContent).toBeFocused();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
63 changes: 63 additions & 0 deletions src/frontend/apps/impress/src/components/SkipToContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { MAIN_LAYOUT_ID } from '@/layouts/conf';

export const SkipToContent = () => {
const { t } = useTranslation();
const router = useRouter();
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 (
<Button
href={`#${MAIN_LAYOUT_ID}`}
onClick={handleClick}
color="tertiary"
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
style={{
opacity: isVisible ? 1 : 0,
pointerEvents: isVisible ? 'auto' : 'none',
transition: 'opacity 0.3s ease-in-out',
position: 'fixed',
top: 'var(--c--theme--spacings--2xs)',
// padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px
left: 'calc(var(--c--theme--spacings--base) + 32px + var(--c--theme--spacings--3xs) + 70px + 12px)',
zIndex: 9999,
whiteSpace: 'nowrap',
}}
>
{t('Go to content')}
</Button>
);
};
1 change: 1 addition & 0 deletions src/frontend/apps/impress/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
133 changes: 68 additions & 65 deletions src/frontend/apps/impress/src/features/header/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,73 +24,76 @@ export const Header = () => {
const logo = config?.theme_customization?.header?.logo;

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: ${colorsTokens['greyscale-000']};
border-bottom: 1px solid ${colorsTokens['greyscale-200']};
`}
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--theme--colors--primary-400) !important;
border-radius: 4px;
}
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: ${colorsTokens['greyscale-000']};
border-bottom: 1px solid ${colorsTokens['greyscale-200']};
`}
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--theme--colors--primary-400) !important;
border-radius: 4px;
}
`}
>
<Image
className="c__image-system-filter"
data-testid="header-icon-docs"
src={logo?.src || '/assets/icon-docs.svg'}
alt=""
width={0}
height={0}
style={{
width: logo?.width || 32,
height: logo?.height || 'auto',
}}
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
className="c__image-system-filter"
data-testid="header-icon-docs"
src={logo?.src || '/assets/icon-docs.svg'}
alt=""
width={0}
height={0}
style={{
width: logo?.width || 32,
height: logo?.height || 'auto',
}}
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>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
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';
Expand Down Expand Up @@ -36,8 +37,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 ${colorsTokens['primary-600']};
outline-offset: -3px;
}
&:focus:not(:focus-visible) {
outline: none;
}
`}
>
<HomeHeader />
{isSmallMobile && (
Expand Down
8 changes: 8 additions & 0 deletions src/frontend/apps/impress/src/layouts/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,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%"
Expand All @@ -79,6 +80,13 @@ export function MainLayoutContent({
$css={css`
overflow-y: auto;
overflow-x: clip;
&:focus {
outline: 3px solid ${colorsTokens['primary-600']};
outline-offset: -3px;
}
&:focus:not(:focus-visible) {
outline: none;
}
`}
>
<Skeleton>
Expand Down
16 changes: 15 additions & 1 deletion src/frontend/apps/impress/src/layouts/PageLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';

import { Box } from '@/components';
import { Footer } from '@/features/footer';
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;
}
Expand All @@ -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 />}
Expand Down
Loading