Files
cinny/src/app/pages/client/ClientLayout.tsx
T
Lotus CIandClaude Opus 5.5 862a128102
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
fix(a11y): skip link focuses <main>; ? dialog and reaction viewer announce (#185)
- 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
2026-09-24 12:15:02 -04:00

66 lines
2.0 KiB
TypeScript

import React, { ReactNode } from 'react';
import { Box, Line } from 'folds';
import { useAtom } from 'jotai';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
import { BookmarksPanel } from '../../features/bookmarks/BookmarksPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
type ClientLayoutProps = {
nav: ReactNode;
children: ReactNode;
};
export function ClientLayout({ nav, children }: ClientLayoutProps) {
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
const screenSize = useScreenSizeContext();
return (
<>
<a
href="#main-content"
style={{
position: 'absolute',
top: '-40px',
left: 0,
padding: '8px 16px',
background: '#000',
color: '#fff',
zIndex: 9999,
borderRadius: '0 0 4px 0',
transition: 'top 0.1s',
}}
onFocus={(e) => {
(e.currentTarget as HTMLElement).style.top = '0';
}}
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>
<Box grow="Yes">
<Box shrink="No" as="nav" aria-label="Room navigation">
{nav}
</Box>
<Box grow="Yes" as="main" id="main-content" tabIndex={-1} style={{ outline: 'none' }}>
{children}
</Box>
{bookmarksOpen && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<BookmarksPanel onClose={() => setBookmarksOpen(false)} />
</>
)}
</Box>
</>
);
}