Skip to content

Commit db7a90d

Browse files
committed
♿️(frontend) add skip link component for keyboard navigation
Improve a11y: skip to main heading, bypass header. RGAA 12.7.
1 parent eee4fbc commit db7a90d

7 files changed

Lines changed: 83 additions & 0 deletions

File tree

src/frontend/src/layout/Layout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { layoutStore } from '@/stores/layout'
55
import { useSnapshot } from 'valtio'
66
import { Footer } from '@/layout/Footer'
77
import { ScreenReaderAnnouncer } from '@/primitives'
8+
import { SkipLink, MAIN_CONTENT_ID } from './SkipLink'
89

910
export type Layout = 'fullpage' | 'centered'
1011

@@ -21,6 +22,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
2122

2223
return (
2324
<>
25+
<SkipLink />
2426
<div
2527
className={css({
2628
display: 'flex',
@@ -35,6 +37,7 @@ export const Layout = ({ children }: { children: ReactNode }) => {
3537
>
3638
{showHeader && <Header />}
3739
<main
40+
id={MAIN_CONTENT_ID}
3841
className={css({
3942
flexGrow: 1,
4043
overflow: 'auto',
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { type MouseEvent, useState } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
4+
export const MAIN_CONTENT_ID = 'main-content'
5+
6+
const hiddenStyle: React.CSSProperties = {
7+
position: 'absolute',
8+
width: 1,
9+
height: 1,
10+
margin: -1,
11+
padding: 0,
12+
overflow: 'hidden',
13+
clip: 'rect(0, 0, 0, 0)',
14+
whiteSpace: 'nowrap',
15+
border: 0,
16+
}
17+
18+
const visibleStyle: React.CSSProperties = {
19+
position: 'fixed',
20+
top: '0.5rem',
21+
left: '50%',
22+
transform: 'translateX(-50%)',
23+
width: 'auto',
24+
height: 'auto',
25+
margin: 0,
26+
padding: '0.625rem 1rem',
27+
overflow: 'visible',
28+
clip: 'auto',
29+
whiteSpace: 'normal',
30+
zIndex: 9999,
31+
backgroundColor: 'white',
32+
color: 'var(--colors-primary-800)',
33+
fontWeight: 500,
34+
fontSize: '0.875rem',
35+
textDecoration: 'none',
36+
border: '1px solid var(--colors-primary-800)',
37+
borderRadius: 4,
38+
outline: '2px solid var(--colors-focus-ring)',
39+
outlineOffset: 2,
40+
}
41+
42+
export const SkipLink = () => {
43+
const { t } = useTranslation()
44+
const [isFocused, setIsFocused] = useState(false)
45+
46+
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
47+
e.preventDefault()
48+
const main = document.getElementById(MAIN_CONTENT_ID)
49+
if (!main) return
50+
51+
const heading = main.querySelector('h1, h2, h3') as HTMLElement | null
52+
const target = heading ?? main
53+
54+
if (!target.hasAttribute('tabindex')) {
55+
target.setAttribute('tabindex', '-1')
56+
}
57+
target.focus()
58+
}
59+
60+
return (
61+
<a
62+
href={`#${MAIN_CONTENT_ID}`}
63+
style={isFocused ? visibleStyle : hiddenStyle}
64+
onFocus={() => setIsFocused(true)}
65+
onBlur={() => setIsFocused(false)}
66+
onClick={handleClick}
67+
>
68+
{t('skipLink')}
69+
</a>
70+
)
71+
}

src/frontend/src/locales/de/global.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"label": "OK"
5353
}
5454
},
55+
"skipLink": "Zum Hauptinhalt springen",
5556
"clipboardContent": {
5657
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
5758
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"

src/frontend/src/locales/en/global.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"label": "OK"
5353
}
5454
},
55+
"skipLink": "Skip to main content",
5556
"clipboardContent": {
5657
"url": "To join the video conference, click on this link: {{roomUrl}}",
5758
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"

src/frontend/src/locales/fr/global.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"label": "OK"
5353
}
5454
},
55+
"skipLink": "Aller au contenu principal",
5556
"clipboardContent": {
5657
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
5758
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"

src/frontend/src/locales/nl/global.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"label": "OK"
5252
}
5353
},
54+
"skipLink": "Naar de hoofdinhoud gaan",
5455
"clipboardContent": {
5556
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
5657
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"

src/frontend/src/styles/index.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ body,
2222
outline: 2px solid transparent;
2323
}
2424

25+
main#main-content :is(h1, h2, h3)[tabindex='-1']:focus {
26+
outline: 2px solid var(--colors-focus-ring);
27+
outline-offset: 2px;
28+
}
29+
2530
[data-rac][data-focus-visible]:not(label, .react-aria-Select),
2631
:is(a, button, input[type='text'], select, textarea):not(
2732
[data-rac]

0 commit comments

Comments
 (0)