Skip to content

Commit e4a72ce

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 e4a72ce

9 files changed

Lines changed: 157 additions & 113 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ and this project adheres to
2222
- 🌐(frontend) localize SR modifier labels #1010
2323
- ⬆️(backend) update python dependencies #1011
2424
- ♿️(frontend) fix focus ring on tab container components #1012
25-
- ️(frontend) use react aria toolbar for arrow key navigation #1005
25+
- ️(frontend) add skip link component for keyboard navigation #1019
2626

2727
## [1.8.0] - 2026-02-20
2828

src/frontend/src/features/rooms/livekit/components/controls/ReactionsToggle.tsx

Lines changed: 73 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import {
1212
} from '@/features/rooms/livekit/components/ReactionPortal'
1313
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
1414
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
15-
import { Popover as RACPopover, Toolbar } from 'react-aria-components'
15+
import {
16+
Popover as RACPopover,
17+
Dialog,
18+
DialogTrigger,
19+
} from 'react-aria-components'
1620
import { FocusScope } from '@react-aria/focus'
1721
import { Participant } from 'livekit-client'
1822
import useRateLimiter from '@/hooks/useRateLimiter'
@@ -40,21 +44,12 @@ export const ReactionsToggle = () => {
4044
const [reactions, setReactions] = useState<Reaction[]>([])
4145
const instanceIdRef = useRef(0)
4246
const room = useRoomContext()
43-
const triggerRef = useRef<HTMLButtonElement>(null)
4447

4548
const [isOpen, setIsOpen] = useState(false)
4649

4750
useRegisterKeyboardShortcut({
4851
id: 'reaction',
49-
handler: () => {
50-
if (isOpen) {
51-
document
52-
.querySelector<HTMLElement>('[role="toolbar"] button')
53-
?.focus()
54-
} else {
55-
setIsOpen(true)
56-
}
57-
},
52+
handler: () => setIsOpen((prev) => !prev),
5853
})
5954

6055
const sendReaction = async (emoji: string) => {
@@ -83,37 +78,6 @@ export const ReactionsToggle = () => {
8378
}, ANIMATION_DURATION)
8479
}
8580

86-
const handleToolbarKeyDown = (e: React.KeyboardEvent) => {
87-
if (e.key === 'Tab') {
88-
e.preventDefault()
89-
document.getElementById('reaction-toggle')?.focus()
90-
return
91-
}
92-
93-
const buttons = Array.from(
94-
(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>(
95-
'[role="toolbar"] button'
96-
)
97-
)
98-
if (buttons.length === 0) return
99-
100-
if (
101-
e.key === 'ArrowRight' &&
102-
document.activeElement === buttons[buttons.length - 1]
103-
) {
104-
e.preventDefault()
105-
e.stopPropagation()
106-
buttons[0].focus()
107-
} else if (
108-
e.key === 'ArrowLeft' &&
109-
document.activeElement === buttons[0]
110-
) {
111-
e.preventDefault()
112-
e.stopPropagation()
113-
buttons[buttons.length - 1].focus()
114-
}
115-
}
116-
11781
const debouncedSendReaction = useRateLimiter({
11882
callback: sendReaction,
11983
maxCalls: 10,
@@ -123,76 +87,73 @@ export const ReactionsToggle = () => {
12387
return (
12488
<>
12589
<div className={css({ position: 'relative' })}>
126-
<ToggleButton
127-
ref={triggerRef}
128-
id="reaction-toggle"
129-
square
130-
variant="primaryDark"
131-
aria-label={t('button')}
132-
tooltip={t('button')}
133-
isSelected={isOpen}
134-
onChange={setIsOpen}
135-
>
136-
<RiEmotionLine />
137-
</ToggleButton>
138-
<RACPopover
139-
triggerRef={triggerRef}
140-
isOpen={isOpen}
141-
onOpenChange={setIsOpen}
142-
placement="top"
143-
offset={8}
144-
isNonModal
145-
shouldCloseOnInteractOutside={() => false}
146-
className={css({
147-
borderRadius: '8px',
148-
padding: '0.35rem',
149-
backgroundColor: 'primaryDark.50',
150-
'&[data-entering]': {
151-
animation: 'fade 200ms ease',
152-
},
153-
'&[data-exiting]': {
154-
animation: 'fade 200ms ease-in reverse',
155-
},
156-
})}
157-
>
158-
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
159-
<FocusScope autoFocus restoreFocus>
160-
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- handles Tab exit and arrow wrapping for the portal-rendered toolbar */}
161-
<div onKeyDownCapture={handleToolbarKeyDown}>
162-
<Toolbar
163-
orientation="horizontal"
164-
aria-label={t('button')}
165-
className={css({
166-
display: 'flex',
167-
gap: '0.5rem',
168-
})}
169-
>
170-
{Object.values(Emoji).map((emoji, index) => (
171-
<Button
172-
key={index}
173-
onPress={() => debouncedSendReaction(emoji)}
174-
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
175-
variant="primaryTextDark"
176-
size="sm"
177-
square
178-
data-attr={`send-reaction-${emoji}`}
179-
>
180-
<img
181-
src={`/assets/reactions/${emoji}.pPng`}
182-
alt=""
183-
className={css({
184-
width: '28px',
185-
height: '28px',
186-
pointerEvents: 'none',
187-
userSelect: 'none',
188-
})}
189-
/>
190-
</Button>
191-
))}
192-
</Toolbar>
193-
</div>
194-
</FocusScope>
195-
</RACPopover>
90+
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
91+
<ToggleButton
92+
square
93+
variant="primaryDark"
94+
aria-label={t('button')}
95+
tooltip={t('button')}
96+
isSelected={isOpen}
97+
onChange={setIsOpen}
98+
>
99+
<RiEmotionLine />
100+
</ToggleButton>
101+
<RACPopover
102+
placement="top"
103+
offset={8}
104+
isNonModal
105+
shouldCloseOnInteractOutside={() => false}
106+
className={css({
107+
borderRadius: '8px',
108+
padding: '0.35rem',
109+
backgroundColor: 'primaryDark.50',
110+
'&[data-entering]': {
111+
animation: 'fade 200ms ease',
112+
},
113+
'&[data-exiting]': {
114+
animation: 'fade 200ms ease-in reverse',
115+
},
116+
})}
117+
>
118+
<Dialog className={css({ outline: 'none' })}>
119+
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
120+
<FocusScope contain autoFocus restoreFocus>
121+
<div
122+
role="toolbar"
123+
aria-orientation="horizontal"
124+
aria-label={t('button')}
125+
className={css({
126+
display: 'flex',
127+
gap: '0.5rem',
128+
})}
129+
>
130+
{Object.values(Emoji).map((emoji, index) => (
131+
<Button
132+
key={index}
133+
onPress={() => debouncedSendReaction(emoji)}
134+
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
135+
variant="primaryTextDark"
136+
size="sm"
137+
square
138+
data-attr={`send-reaction-${emoji}`}
139+
>
140+
<img
141+
src={`/assets/reactions/${emoji}.png`}
142+
alt=""
143+
className={css({
144+
width: '28px',
145+
height: '28px',
146+
pointerEvents: 'none',
147+
userSelect: 'none',
148+
})}
149+
/>
150+
</Button>
151+
))}
152+
</div>
153+
</FocusScope>
154+
</Dialog>
155+
</RACPopover>
156+
</DialogTrigger>
196157
</div>
197158
<ReactionPortals reactions={reactions} />
198159
</>

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)