diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f3b5e6f02..50f612b167 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
@@ -46,7 +54,6 @@ and this project adheres to
- 🔥(backend) remove api managing templates
-
## [3.9.0] - 2025-11-10
### Added
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts
index 349af4e1c7..b89d8bf3df 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts
@@ -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();
+ });
+});
diff --git a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts
index 6bfc37fd22..127caeb7ce 100644
--- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts
+++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts
@@ -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');
diff --git a/src/frontend/apps/impress/src/components/SkipToContent.tsx b/src/frontend/apps/impress/src/components/SkipToContent.tsx
new file mode 100644
index 0000000000..257bcd9ea0
--- /dev/null
+++ b/src/frontend/apps/impress/src/components/SkipToContent.tsx
@@ -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 (
+
+ );
+};
diff --git a/src/frontend/apps/impress/src/components/index.ts b/src/frontend/apps/impress/src/components/index.ts
index 89916d0fe8..904c11bada 100644
--- a/src/frontend/apps/impress/src/components/index.ts
+++ b/src/frontend/apps/impress/src/components/index.ts
@@ -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';
diff --git a/src/frontend/apps/impress/src/features/header/components/Header.tsx b/src/frontend/apps/impress/src/features/header/components/Header.tsx
index 3ab656615b..a23d314b68 100644
--- a/src/frontend/apps/impress/src/features/header/components/Header.tsx
+++ b/src/frontend/apps/impress/src/features/header/components/Header.tsx
@@ -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';
@@ -24,73 +24,76 @@ export const Header = () => {
const logo = config?.theme_customization?.header?.logo;
return (
-
- {!isDesktop && }
-
+
+
- }
+
-
-
-
-
- {!isDesktop ? (
-
-
-
- ) : (
-
-
-
-
-
- )}
-
+
+
+
+
+
+ {!isDesktop ? (
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+ >
);
};
diff --git a/src/frontend/apps/impress/src/features/home/components/HomeContent.tsx b/src/frontend/apps/impress/src/features/home/components/HomeContent.tsx
index 3674cfb4d6..4c5ac15c97 100644
--- a/src/frontend/apps/impress/src/features/home/components/HomeContent.tsx
+++ b/src/frontend/apps/impress/src/features/home/components/HomeContent.tsx
@@ -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';
@@ -36,8 +37,19 @@ export function HomeContent() {
{isSmallMobile && (
diff --git a/src/frontend/apps/impress/src/layouts/MainLayout.tsx b/src/frontend/apps/impress/src/layouts/MainLayout.tsx
index 3862dd5690..fec488e081 100644
--- a/src/frontend/apps/impress/src/layouts/MainLayout.tsx
+++ b/src/frontend/apps/impress/src/layouts/MainLayout.tsx
@@ -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%"
@@ -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;
+ }
`}
>
diff --git a/src/frontend/apps/impress/src/layouts/PageLayout.tsx b/src/frontend/apps/impress/src/layouts/PageLayout.tsx
index 84c7a184ac..b4a70a9708 100644
--- a/src/frontend/apps/impress/src/layouts/PageLayout.tsx
+++ b/src/frontend/apps/impress/src/layouts/PageLayout.tsx
@@ -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({
{!isDesktop && }