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
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:
co-authored by
Claude Opus 5.5
parent
c0a9b2da6b
commit
862a128102
@@ -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}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user