fix(a11y): skip link focuses <main>; ? dialog and reaction viewer announce (#185)
CI / Build & Quality Checks (push) Canceled after 1m33s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s

- Skip link: following #main-content left focus on <body> (the <main>
  target wasn't focusable), so nothing was announced and the URL got a
  stray fragment. <main> is now tabIndex=-1 and the link focuses it.
- Keyboard-shortcuts dialog (?): no role, and focus stayed in the
  timeline, so it opened silently. Now role="dialog" aria-modal, and focus
  moves into it (Escape still returns focus to where you were).
- Reaction viewer (both the reaction context-menu path and "View
  Reactions"): same — role="dialog" aria-modal aria-label="Reactions",
  focus moves in.

Keyboard-only checks (Playwright): Tab → skip link → Enter focuses
<main>; Tab reaches the room list, Enter opens a room, typing lands in the
composer, Enter sends (verified on the server); focus ring visible. "?"
opens the dialog with focus inside, Escape returns to the same element,
"?" in the composer stays text. Topic viewer, Search dialog and reaction
viewer all return focus to their opener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-24 12:15:02 -04:00
co-authored by Claude Opus 5.5
parent c0a9b2da6b
commit 862a128102
4 changed files with 42 additions and 7 deletions
+13 -2
View File
@@ -223,14 +223,25 @@ export const MessageAllReactionItem = as<
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// #185 P5: move focus into the viewer so it is announced (it opened
// silently with focus left on the reaction behind it).
initialFocus: '[data-reaction-viewer]',
returnFocusOnDeactivate: false,
onDeactivate: () => handleClose(),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<Modal
variant="Surface"
size="300"
role="dialog"
aria-modal="true"
aria-label="Reactions"
data-reaction-viewer=""
tabIndex={-1}
style={{ outline: 'none' }}
>
<ReactionViewer
room={room}
relations={relations}
+13 -2
View File
@@ -211,13 +211,24 @@ export const Reactions = as<'div', ReactionsProps>(
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// #185 P5: move focus into the viewer so it is announced (it opened
// silently with focus left on the reaction behind it).
initialFocus: '[data-reaction-viewer]',
onDeactivate: () => setViewer(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<Modal
variant="Surface"
size="300"
role="dialog"
aria-modal="true"
aria-label="Reactions"
data-reaction-viewer=""
tabIndex={-1}
style={{ outline: 'none' }}
>
<ReactionViewer
room={room}
initialKey={typeof viewer === 'string' ? viewer : undefined}
@@ -139,7 +139,9 @@ export function KeyboardShortcutsDialog() {
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
// #185 P2: move focus into the dialog so a screen reader announces
// it (it opened silently with focus left behind in the timeline).
initialFocus: '#keyboard-shortcuts-dialog',
onDeactivate: closeDialog,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
@@ -147,8 +149,12 @@ export function KeyboardShortcutsDialog() {
>
<Dialog
variant="Surface"
id="keyboard-shortcuts-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="keyboard-shortcuts-dialog-title"
style={modalStyle}
tabIndex={-1}
style={{ ...modalStyle, outline: 'none' }}
>
<Header
style={{
+8 -1
View File
@@ -34,6 +34,13 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
onBlur={(e) => {
(e.currentTarget as HTMLElement).style.top = '-40px';
}}
// #185 P1: following the fragment left focus on <body> (the <main> target
// wasn't focusable), so screen readers announced nothing and the URL got
// a stray #main-content. Move focus to <main> explicitly instead.
onClick={(e) => {
e.preventDefault();
document.getElementById('main-content')?.focus();
}}
>
Skip to main content
</a>
@@ -41,7 +48,7 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) {
<Box shrink="No" as="nav" aria-label="Room navigation">
{nav}
</Box>
<Box grow="Yes" as="main" id="main-content">
<Box grow="Yes" as="main" id="main-content" tabIndex={-1} style={{ outline: 'none' }}>
{children}
</Box>
{bookmarksOpen && (