Skip to content

Commit b588541

Browse files
committed
✨(a11y) add skip to content button for keyboard accessibility
add SkipToContent component to meet RGAA skiplink requirement Signed-off-by: Cyril <[email protected]>
1 parent d403878 commit b588541

File tree

6 files changed

+173
-1
lines changed

6 files changed

+173
-1
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { useRouter } from 'next/router';
2+
import { useEffect, useState } from 'react';
3+
import { useTranslation } from 'react-i18next';
4+
import styled, { css } from 'styled-components';
5+
6+
import { useCunninghamTheme } from '@/cunningham';
7+
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
8+
9+
import { Box } from './Box';
10+
11+
const SkipLink = styled(Box)<{
12+
$colorsTokens: Record<string, string>;
13+
$spacingsTokens: Record<string, string>;
14+
}>`
15+
${({ $colorsTokens, $spacingsTokens }) => css`
16+
position: fixed;
17+
top: 0.5rem;
18+
/* Position: padding header + logo(32px) + gap(3xs≈4px) + text "Docs"(≈70px) + 12px */
19+
left: calc(
20+
${$spacingsTokens['base']} + 32px + ${$spacingsTokens['3xs']} + 70px +
21+
12px
22+
);
23+
z-index: 9999;
24+
25+
/* Figma specs - Layout */
26+
display: inline-flex;
27+
padding: ${$spacingsTokens['xs']} ${$spacingsTokens['xs']};
28+
flex-direction: column;
29+
justify-content: center;
30+
align-items: flex-start;
31+
gap: ${$spacingsTokens['4xs']};
32+
33+
/* Figma specs - Style */
34+
border-radius: ${$spacingsTokens['3xs']};
35+
border: 1px solid
36+
var(--c--theme--colors--primary-300, ${$colorsTokens['primary-300']});
37+
background: var(
38+
--c--theme--colors--primary-100,
39+
${$colorsTokens['primary-100']}
40+
);
41+
box-shadow: 0 6px 18px 0 rgba(0, 0, 145, 0.05);
42+
43+
/* Figma specs - Typography */
44+
color: ${$colorsTokens['primary-600']};
45+
font-family: var(--c--theme--font--families--base, 'Marianne Variable');
46+
font-size: 14px;
47+
font-style: normal;
48+
font-weight: 500;
49+
line-height: 18px;
50+
51+
/* Skip link behavior - Fondu enchainé */
52+
text-decoration: none;
53+
white-space: nowrap;
54+
opacity: 0;
55+
pointer-events: none;
56+
transition: opacity 0.3s ease-in-out;
57+
58+
&:focus,
59+
&:focus-visible {
60+
opacity: 1;
61+
pointer-events: auto;
62+
outline: 2px solid var(--c--theme--colors--primary-400);
63+
outline-offset: ${$spacingsTokens['4xs']};
64+
}
65+
66+
&:focus:not(:focus-visible) {
67+
outline: none;
68+
}
69+
70+
&:hover {
71+
background: var(
72+
--c--theme--colors--primary-200,
73+
${$colorsTokens['primary-200']}
74+
);
75+
color: ${$colorsTokens['primary-700']};
76+
}
77+
`}
78+
`;
79+
80+
export const SkipToContent = () => {
81+
const { t } = useTranslation();
82+
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
83+
const router = useRouter();
84+
const [isMounted, setIsMounted] = useState(false);
85+
86+
// Prevent SSR flash - only render client-side
87+
useEffect(() => {
88+
setIsMounted(true);
89+
}, []);
90+
91+
// Reset focus after route change so first TAB goes to skip link
92+
useEffect(() => {
93+
const handleRouteChange = () => {
94+
(document.activeElement as HTMLElement)?.blur();
95+
96+
document.body.setAttribute('tabindex', '-1');
97+
document.body.focus({ preventScroll: true });
98+
99+
setTimeout(() => {
100+
document.body.removeAttribute('tabindex');
101+
}, 100);
102+
};
103+
104+
router.events.on('routeChangeComplete', handleRouteChange);
105+
return () => {
106+
router.events.off('routeChangeComplete', handleRouteChange);
107+
};
108+
}, [router.events]);
109+
110+
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
111+
e.preventDefault();
112+
const mainContent = document.getElementById(MAIN_LAYOUT_ID);
113+
if (mainContent) {
114+
mainContent.focus();
115+
mainContent.scrollIntoView({ behavior: 'smooth', block: 'start' });
116+
}
117+
};
118+
119+
// Don't render during SSR to prevent flash
120+
if (!isMounted) {
121+
return null;
122+
}
123+
124+
return (
125+
<SkipLink
126+
as="a"
127+
href={`#${MAIN_LAYOUT_ID}`}
128+
onClick={handleClick}
129+
$colorsTokens={colorsTokens}
130+
$spacingsTokens={spacingsTokens}
131+
>
132+
{t('Go to content')}
133+
</SkipLink>
134+
);
135+
};

src/frontend/apps/impress/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ export * from './Loading';
1111
export * from './modal';
1212
export * from './Overlayer';
1313
export * from './separators';
14+
export * from './SkipToContent';
1415
export * from './Text';
1516
export * from './TextErrors';

src/frontend/apps/impress/src/features/home/components/HomeContent.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Box, Icon, Text } from '@/components';
66
import { useCunninghamTheme } from '@/cunningham';
77
import { Footer } from '@/features/footer';
88
import { LeftPanel } from '@/features/left-panel';
9+
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
910
import { useResponsiveStore } from '@/stores';
1011

1112
import SC1ResponsiveEn from '../assets/SC1-responsive-en.png';
@@ -36,8 +37,19 @@ export function HomeContent() {
3637
<Box
3738
as="main"
3839
role="main"
40+
id={MAIN_LAYOUT_ID}
41+
tabIndex={-1}
3942
className="--docs--home-content"
4043
aria-label={t('Main content')}
44+
$css={css`
45+
&:focus {
46+
outline: 3px solid ${colorsTokens['primary-600']};
47+
outline-offset: -3px;
48+
}
49+
&:focus:not(:focus-visible) {
50+
outline: none;
51+
}
52+
`}
4153
>
4254
<HomeHeader />
4355
{isSmallMobile && (

src/frontend/apps/impress/src/layouts/MainLayout.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export function MainLayoutContent({
6363
role="main"
6464
aria-label={t('Main content')}
6565
id={MAIN_LAYOUT_ID}
66+
tabIndex={-1}
6667
$align="center"
6768
$flex={1}
6869
$width="100%"
@@ -79,6 +80,13 @@ export function MainLayoutContent({
7980
$css={css`
8081
overflow-y: auto;
8182
overflow-x: clip;
83+
&:focus {
84+
outline: 3px solid ${colorsTokens['primary-600']};
85+
outline-offset: -3px;
86+
}
87+
&:focus:not(:focus-visible) {
88+
outline: none;
89+
}
8290
`}
8391
>
8492
<Skeleton>

src/frontend/apps/impress/src/layouts/PageLayout.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { PropsWithChildren } from 'react';
22
import { useTranslation } from 'react-i18next';
3+
import { css } from 'styled-components';
34

45
import { Box } from '@/components';
56
import { Footer } from '@/features/footer';
67
import { HEADER_HEIGHT, Header } from '@/features/header';
78
import { LeftPanel } from '@/features/left-panel';
89
import { useResponsiveStore } from '@/stores';
910

11+
import { MAIN_LAYOUT_ID } from './conf';
12+
1013
interface PageLayoutProps {
1114
withFooter?: boolean;
1215
}
@@ -27,8 +30,19 @@ export function PageLayout({
2730
<Box
2831
as="main"
2932
role="main"
33+
id={MAIN_LAYOUT_ID}
34+
tabIndex={-1}
3035
$width="100%"
31-
$css="flex-grow:1;"
36+
$css={css`
37+
flex-grow: 1;
38+
&:focus {
39+
outline: 3px solid var(--c--theme--colors--primary-600);
40+
outline-offset: -3px;
41+
}
42+
&:focus:not(:focus-visible) {
43+
outline: none;
44+
}
45+
`}
3246
aria-label={t('Main content')}
3347
>
3448
{!isDesktop && <LeftPanel />}

src/frontend/apps/impress/src/pages/_app.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { AppProps } from 'next/app';
22
import Head from 'next/head';
33
import { useTranslation } from 'react-i18next';
44

5+
import { SkipToContent } from '@/components';
56
import { AppProvider } from '@/core/';
67
import { useCunninghamTheme } from '@/cunningham';
78
import { useOffline, useSWRegister } from '@/features/service-worker/';
@@ -49,6 +50,7 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
4950
/>
5051
<meta name="viewport" content="width=device-width, initial-scale=1" />
5152
</Head>
53+
<SkipToContent />
5254
<AppProvider>{getLayout(<Component {...pageProps} />)}</AppProvider>
5355
</>
5456
);

0 commit comments

Comments
 (0)