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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
135 changes: 135 additions & 0 deletions src/frontend/apps/impress/src/components/SkipToContent.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
$spacingsTokens: Record<string, string>;
}>`
${({ $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<HTMLAnchorElement>) => {
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 (
<SkipLink
as="a"
href={`#${MAIN_LAYOUT_ID}`}
onClick={handleClick}
$colorsTokens={colorsTokens}
$spacingsTokens={spacingsTokens}
>
{t('Go to content')}
</SkipLink>
);
};
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';
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
2 changes: 2 additions & 0 deletions src/frontend/apps/impress/src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/';
Expand Down Expand Up @@ -49,6 +50,7 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<SkipToContent />
<AppProvider>{getLayout(<Component {...pageProps} />)}</AppProvider>
</>
);
Expand Down
Loading