From b588541f6dc9d3102e59492f4fa698c0c18540d9 Mon Sep 17 00:00:00 2001 From: Cyril Date: Tue, 18 Nov 2025 11:09:48 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8(a11y)=20add=20skip=20to=20content?= =?UTF-8?q?=20button=20for=20keyboard=20accessibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add SkipToContent component to meet RGAA skiplink requirement Signed-off-by: Cyril --- .../impress/src/components/SkipToContent.tsx | 135 ++++++++++++++++++ .../apps/impress/src/components/index.ts | 1 + .../features/home/components/HomeContent.tsx | 12 ++ .../apps/impress/src/layouts/MainLayout.tsx | 8 ++ .../apps/impress/src/layouts/PageLayout.tsx | 16 ++- src/frontend/apps/impress/src/pages/_app.tsx | 2 + 6 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/frontend/apps/impress/src/components/SkipToContent.tsx 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..ad3f0c34f3 --- /dev/null +++ b/src/frontend/apps/impress/src/components/SkipToContent.tsx @@ -0,0 +1,135 @@ +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styled, { css } from 'styled-components'; + +import { useCunninghamTheme } from '@/cunningham'; +import { MAIN_LAYOUT_ID } from '@/layouts/conf'; + +import { Box } from './Box'; + +const SkipLink = styled(Box)<{ + $colorsTokens: Record; + $spacingsTokens: Record; +}>` + ${({ $colorsTokens, $spacingsTokens }) => css` + position: fixed; + top: 0.5rem; + /* Position: padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px */ + left: calc( + ${$spacingsTokens['base']} + 32px + ${$spacingsTokens['3xs']} + 70px + + 12px + ); + z-index: 9999; + + /* Figma specs - Layout */ + display: inline-flex; + padding: ${$spacingsTokens['xs']} ${$spacingsTokens['xs']}; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: ${$spacingsTokens['4xs']}; + + /* Figma specs - Style */ + border-radius: ${$spacingsTokens['3xs']}; + border: 1px solid + var(--c--theme--colors--primary-300, ${$colorsTokens['primary-300']}); + background: var( + --c--theme--colors--primary-100, + ${$colorsTokens['primary-100']} + ); + box-shadow: 0 6px 18px 0 rgba(0, 0, 145, 0.05); + + /* Figma specs - Typography */ + color: ${$colorsTokens['primary-600']}; + font-family: var(--c--theme--font--families--base, 'Marianne Variable'); + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 18px; + + /* Skip link behavior - Fondu enchainé */ + text-decoration: none; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease-in-out; + + &:focus, + &:focus-visible { + opacity: 1; + pointer-events: auto; + outline: 2px solid var(--c--theme--colors--primary-400); + outline-offset: ${$spacingsTokens['4xs']}; + } + + &:focus:not(:focus-visible) { + outline: none; + } + + &:hover { + background: var( + --c--theme--colors--primary-200, + ${$colorsTokens['primary-200']} + ); + color: ${$colorsTokens['primary-700']}; + } + `} +`; + +export const SkipToContent = () => { + const { t } = useTranslation(); + const { colorsTokens, spacingsTokens } = useCunninghamTheme(); + const router = useRouter(); + const [isMounted, setIsMounted] = useState(false); + + // Prevent SSR flash - only render client-side + useEffect(() => { + setIsMounted(true); + }, []); + + // 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' }); + } + }; + + // Don't render during SSR to prevent flash + if (!isMounted) { + return null; + } + + return ( + + {t('Go to content')} + + ); +}; 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/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 && } diff --git a/src/frontend/apps/impress/src/pages/_app.tsx b/src/frontend/apps/impress/src/pages/_app.tsx index 75596c5e21..00fbe76f0d 100644 --- a/src/frontend/apps/impress/src/pages/_app.tsx +++ b/src/frontend/apps/impress/src/pages/_app.tsx @@ -2,6 +2,7 @@ import type { AppProps } from 'next/app'; import Head from 'next/head'; import { useTranslation } from 'react-i18next'; +import { SkipToContent } from '@/components'; import { AppProvider } from '@/core/'; import { useCunninghamTheme } from '@/cunningham'; import { useOffline, useSWRegister } from '@/features/service-worker/'; @@ -49,6 +50,7 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) { /> + {getLayout()} ); From 7311d972e82fd60afe2d2009b79a80c5c6642681 Mon Sep 17 00:00:00 2001 From: Cyril Date: Wed, 19 Nov 2025 09:21:30 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85(frontend)=20add=20e2e=20test=20fo?= =?UTF-8?q?r=20skiplink=20and=20fix=20broken=20accessibility=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensures skiplink behavior is tested and stabilizes a failing accessibility test Signed-off-by: Cyril --- CHANGELOG.md | 1 + .../e2e/__tests__/app-impress/header.spec.ts | 24 +++++++++++++++++++ .../__tests__/app-impress/language.spec.ts | 1 + 3 files changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a830190d7..d995f2ff84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ 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) add skip to content button for keyboard accessibility #1624 - 🐛(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 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');