Skip to content

Commit 62aa091

Browse files
committed
merge upstream/main into feature/frontend-performance-monitoring
Resolve layout.tsx: nest AccessibilityProvider (main) with PerformanceMonitoringProvider (PR) under ThemeProvider so a11y and Core Web Vitals both apply. Made-with: Cursor
2 parents bee3e1a + 635376c commit 62aa091

38 files changed

Lines changed: 2215 additions & 1216 deletions

docs/ACCESSIBILITY.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Accessibility on TeachLink
2+
3+
This app ships a layered accessibility toolkit aimed at **WCAG 2.1 Level AA** patterns. Automated checks catch common failures; they do **not** replace testing with screen readers and keyboard-only navigation.
4+
5+
## Architecture
6+
7+
| Piece | Role |
8+
| --- | --- |
9+
| `AccessibilityProvider` | Global context: `announce`, motion preference, keyboard modality, `runPageAudit`. |
10+
| `ScreenReaderSupport` | Permanent **polite** and **assertive** live regions for reliable announcements. |
11+
| `KeyboardNavigation` | **Alt+M** focuses main content; **Shift+?** opens a shortcuts dialog (focus-trapped). Toolbar **roving** focus with `[data-roving-root]`. |
12+
| `AccessibilityAudit` | Dev-only (by default) floating panel with heuristic DOM checks. |
13+
| `useAccessibility()` | Reads context, or a safe fallback when used outside the provider. |
14+
| `accessibilityUtils` | Focus helpers, contrast math, `checkAccessibilityIssues`, `runAccessibilityAudit`. |
15+
16+
## Using the provider
17+
18+
The root layout wraps the app with `AccessibilityProvider`. Pass `enableDevAudit={false}` in production if you want to hide the audit FAB entirely, or set `NODE_ENV` so the default dev panel is off.
19+
20+
## Announcements
21+
22+
```tsx
23+
import { useAccessibility } from '@/hooks/useAccessibility';
24+
25+
function SaveButton() {
26+
const { announce } = useAccessibility();
27+
return (
28+
<button type="button" onClick={() => announce('Changes saved', 'polite')}>
29+
Save
30+
</button>
31+
);
32+
}
33+
```
34+
35+
Use **assertive** only for urgent errors or time-sensitive status.
36+
37+
## Keyboard and landmarks
38+
39+
- Give the primary `<main>` a stable id such as `main-content` so skip links and **Alt+M** work everywhere. There should be **exactly one** `<main>` (or `role="main"`) per view.
40+
- For horizontal toolbars, add `data-roving-root` on the toolbar container. **Left/Right arrow** moves among buttons, links, tabs, and elements marked with `data-roving-item` (including those using `tabindex="-1"` for roving patterns).
41+
42+
## What automation does *not* prove
43+
44+
- **WCAG 2.1 AA** for the whole product requires page-by-page review (contrast in context, timing, reflow, errors, etc.).
45+
- The audit panel and `checkAccessibilityIssues` only flag **some** DOM patterns. They miss false positives/negatives and cannot judge screen reader UX.
46+
- **All keyboard paths** and **all screen reader announcements** still need manual QA on real flows.
47+
48+
## ARIA checklist (authoring)
49+
50+
1. Every interactive control has a computed **accessible name** (visible text, `aria-label`, or `aria-labelledby`).
51+
2. Form fields are labeled with a wrapping `<label>`, `<label htmlFor>`, or `aria-label` / `aria-labelledby`.
52+
3. Images convey meaning with `alt`; decorative images use `alt=""`.
53+
4. Headings describe structure without skipped levels.
54+
5. Expandable regions use `aria-expanded`; dialogs use `role="dialog"`, `aria-modal="true"`, and initial focus management.
55+
6. Prefer native `<button>` and `<a href>` over generic elements with scripts.
56+
57+
## Testing
58+
59+
- Navigate the primary tasks **without a mouse** (including modals, forms, and media).
60+
- Run **VoiceOver** (macOS) or **NVDA** (Windows) on critical flows; verify focus order and live region behavior.
61+
- Use the in-app **Accessibility audit** (development) to catch missing `alt`, labels, names, landmarks, `lang`, and duplicate `id`s—then fix and re-test manually.
62+
63+
For more examples, see `src/app/components/accessibility/README.md` and `ACCESSIBILITY_IMPLEMENTATION_GUIDE.md` in the repo root.

package-lock.json

Lines changed: 0 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@
7070
"@types/node": "^20",
7171
"@types/react": "^18.3.27",
7272
"@types/react-dom": "^18.3.7",
73-
"@types/socket.io-client": "^1.4.36",
7473
"@vitejs/plugin-react-swc": "^3.10.2",
7574
"eslint": "^9",
7675
"eslint-config-next": "15.3.1",

src/app/layout.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { PerformanceMonitoringProvider } from '@/hooks/usePerformanceMonitoring'
1111
import PrefetchingEngine from '@/components/performance/PrefetchingEngine';
1212
import StateManagerIntegration from '@/components/state/StateManagerIntegration';
1313
import { PWAManager } from '@/components/pwa/PWAManager';
14+
import { AccessibilityProvider } from '@/components/accessibility/AccessibilityProvider';
1415

1516
const geistSans = Geist({
1617
// ...
@@ -43,15 +44,17 @@ export default function RootLayout({
4344
<InternationalizationEngine>
4445
<CulturalAdaptationManager>
4546
<ThemeProvider>
46-
<PerformanceMonitoringProvider>
47-
<OfflineModeProvider>
48-
<PWAManager />
49-
<StateManagerIntegration />
50-
<PerformanceMonitor />
51-
<PrefetchingEngine />
52-
{children}
53-
</OfflineModeProvider>
54-
</PerformanceMonitoringProvider>
47+
<AccessibilityProvider pageLabel="TeachLink — main application">
48+
<PerformanceMonitoringProvider>
49+
<OfflineModeProvider>
50+
<PWAManager />
51+
<StateManagerIntegration />
52+
<PerformanceMonitor />
53+
<PrefetchingEngine />
54+
{children}
55+
</OfflineModeProvider>
56+
</PerformanceMonitoringProvider>
57+
</AccessibilityProvider>
5558
</ThemeProvider>
5659
</CulturalAdaptationManager>
5760
</InternationalizationEngine>
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useId, useState, type RefObject } from 'react';
4+
import { useAccessibility, useFocusTrap } from '@/hooks/useAccessibility';
5+
import { getWCAGLevel, type AccessibilityIssue } from '@/utils/accessibilityUtils';
6+
import { AlertCircle, CheckCircle2, ClipboardList, X } from 'lucide-react';
7+
8+
/**
9+
* Development helper: heuristic WCAG-oriented checks over the document.
10+
* Does not replace manual testing with assistive technology.
11+
*/
12+
export function AccessibilityAudit() {
13+
const { runPageAudit, announce } = useAccessibility();
14+
const [open, setOpen] = useState(false);
15+
const [issues, setIssues] = useState<AccessibilityIssue[]>([]);
16+
const [busy, setBusy] = useState(false);
17+
const titleId = useId();
18+
const descId = useId();
19+
const trapRef = useFocusTrap(open);
20+
21+
const run = useCallback(() => {
22+
setBusy(true);
23+
window.requestAnimationFrame(() => {
24+
const found = runPageAudit();
25+
setIssues(found);
26+
setBusy(false);
27+
const level = getWCAGLevel(found);
28+
const critical = found.filter((i) => i.severity === 'critical').length;
29+
announce(
30+
`Audit complete. ${found.length} findings. WCAG estimate ${level}.`,
31+
critical > 0 ? 'assertive' : 'polite',
32+
);
33+
});
34+
}, [runPageAudit, announce]);
35+
36+
useEffect(() => {
37+
if (!open) return;
38+
run();
39+
}, [open, run]);
40+
41+
useEffect(() => {
42+
if (!open) return;
43+
const onKeyDown = (e: KeyboardEvent) => {
44+
if (e.key === 'Escape') {
45+
e.preventDefault();
46+
setOpen(false);
47+
}
48+
};
49+
document.addEventListener('keydown', onKeyDown);
50+
return () => document.removeEventListener('keydown', onKeyDown);
51+
}, [open]);
52+
53+
const level = getWCAGLevel(issues);
54+
55+
return (
56+
<>
57+
<button
58+
type="button"
59+
className="fixed bottom-4 left-4 z-[9998] flex h-12 w-12 items-center justify-center rounded-full border border-amber-300 bg-amber-500 text-white shadow-lg focus:outline-none focus:ring-2 focus:ring-amber-400 focus:ring-offset-2 dark:border-amber-600 dark:bg-amber-600 dark:focus:ring-offset-gray-950"
60+
aria-label="Open accessibility audit (development only)"
61+
aria-expanded={open}
62+
aria-controls="a11y-audit-dialog"
63+
onClick={() => setOpen((o) => !o)}
64+
>
65+
<ClipboardList size={22} aria-hidden="true" />
66+
</button>
67+
68+
{open ? (
69+
<>
70+
<div
71+
className="fixed inset-0 z-[9997] bg-black/30"
72+
aria-hidden="true"
73+
onClick={() => setOpen(false)}
74+
/>
75+
<section
76+
id="a11y-audit-dialog"
77+
ref={trapRef as RefObject<HTMLElement>}
78+
role="dialog"
79+
aria-modal="true"
80+
aria-labelledby={titleId}
81+
aria-describedby={descId}
82+
aria-busy={busy}
83+
className="fixed bottom-20 left-4 z-[9998] flex max-h-[min(70vh,28rem)] w-[min(calc(100vw-2rem),22rem)] flex-col rounded-xl border border-gray-200 bg-white shadow-2xl outline-none dark:border-gray-700 dark:bg-gray-900"
84+
>
85+
<p id={descId} className="sr-only">
86+
Automated accessibility scan of the page. Results are heuristic and must be verified
87+
manually with keyboard and screen reader testing.
88+
</p>
89+
<header className="flex items-center justify-between gap-2 border-b border-gray-100 px-3 py-2 dark:border-gray-800">
90+
<h2 id={titleId} className="text-sm font-semibold text-gray-900 dark:text-gray-50">
91+
A11y audit
92+
</h2>
93+
<button
94+
type="button"
95+
className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800 focus:outline-none focus:ring-2 focus:ring-amber-500 dark:hover:bg-gray-800 dark:hover:text-gray-100"
96+
aria-label="Close audit panel"
97+
onClick={() => setOpen(false)}
98+
>
99+
<X size={18} aria-hidden="true" />
100+
</button>
101+
</header>
102+
103+
<div className="flex flex-1 flex-col gap-2 overflow-hidden p-3">
104+
<div className="flex items-center justify-between text-xs">
105+
<span className="text-gray-600 dark:text-gray-400">Heuristic WCAG mapping</span>
106+
<span
107+
className={`rounded-full px-2 py-0.5 font-medium ${
108+
level === 'Fail'
109+
? 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200'
110+
: level === 'AAA'
111+
? 'bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200'
112+
: 'bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-100'
113+
}`}
114+
>
115+
{level}
116+
</span>
117+
</div>
118+
119+
<button
120+
type="button"
121+
className="rounded-lg bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-500 disabled:opacity-60"
122+
onClick={run}
123+
disabled={busy}
124+
>
125+
{busy ? 'Scanning…' : 'Run again'}
126+
</button>
127+
128+
<div
129+
className="min-h-0 flex-1 overflow-y-auto text-sm"
130+
role="region"
131+
aria-label="Scan results"
132+
tabIndex={0}
133+
>
134+
{busy ? (
135+
<p className="text-gray-500 dark:text-gray-400">Scanning DOM…</p>
136+
) : issues.length === 0 ? (
137+
<div className="flex flex-col items-center gap-2 py-6 text-center text-gray-600 dark:text-gray-400">
138+
<CheckCircle2 className="text-green-600" size={36} aria-hidden="true" />
139+
<p>No issues flagged by automated checks.</p>
140+
</div>
141+
) : (
142+
<ul className="space-y-2">
143+
{issues.map((issue) => (
144+
<li
145+
key={issue.id}
146+
className="rounded-lg border border-gray-100 bg-gray-50 p-2 dark:border-gray-800 dark:bg-gray-950"
147+
>
148+
<div className="flex gap-2">
149+
<AlertCircle
150+
className="mt-0.5 shrink-0 text-amber-600 dark:text-amber-400"
151+
size={16}
152+
aria-hidden="true"
153+
/>
154+
<div>
155+
<p className="font-medium text-gray-900 dark:text-gray-100">
156+
{issue.message}
157+
</p>
158+
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
159+
{issue.suggestion}
160+
</p>
161+
<p className="mt-1 text-xs text-gray-500">
162+
WCAG: {issue.wcagCriteria.join(', ')}
163+
</p>
164+
</div>
165+
</div>
166+
</li>
167+
))}
168+
</ul>
169+
)}
170+
</div>
171+
</div>
172+
</section>
173+
</>
174+
) : null}
175+
</>
176+
);
177+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use client';
2+
3+
import { createContext } from 'react';
4+
import type { AccessibilityIssue } from '@/utils/accessibilityUtils';
5+
6+
export type AnnouncePriority = 'polite' | 'assertive';
7+
8+
export interface AccessibilityContextValue {
9+
announce: (message: string, priority?: AnnouncePriority) => void;
10+
prefersReducedMotion: boolean;
11+
/** Keyboard modality for focus visibility (Tab vs pointer); use for :focus-visible styling */
12+
isKeyboardUser: boolean;
13+
/** Run heuristic WCAG-oriented checks on the document body */
14+
runPageAudit: () => AccessibilityIssue[];
15+
/** Extra announcements for dynamic regions (off by default) */
16+
verboseLiveRegions: boolean;
17+
}
18+
19+
export const AccessibilityContext = createContext<AccessibilityContextValue | null>(null);

0 commit comments

Comments
 (0)