Skip to content

Commit a2a8f76

Browse files
feat(a11y): implement WCAG 2.1 AA accessibility improvements
- Add skip-to-content link and RouteAnnouncer to root layout (SC 2.4.1, 4.1.3) - Fix admin layout: remove duplicate html/body wrappers, add admin skip link - AdminSidebar: replace div+onClick with button+aria-expanded/controls for expandable items; add aria-current, aria-hidden on icons (SC 4.1.2) - AdminHeader: add aria-label/expanded/haspopup to notification and user menu buttons; sr-only unread count badge; label search input; role=menu/ menuitem on dropdowns; demote header title to <p> to preserve h1 hierarchy - Profile page: wrap in <main>, add tablist/tab/tabpanel ARIA pattern, role=dialog+aria-modal+Escape on edit modal, role=status on loading overlay - CourseCard: add aria-label to action buttons and rating badge; aria-hidden on decorative icons (SC 2.4.6, 4.1.2) - Add axe-core accessibility test suite (5 fixtures, all passing) with wcag2a/wcag2aa/wcag21aa/best-practice tag filters - Add test:a11y and audit:a11y npm scripts - Install @ducanh2912/next-pwa to unblock jest test runner
1 parent a123741 commit a2a8f76

8 files changed

Lines changed: 511 additions & 98 deletions

File tree

frontend/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
"prepare": "husky install",
2121
"analyze": "ANALYZE=true next build",
2222
"lighthouse": "lighthouse http://localhost:3000 --output=json --output-path=./lighthouse-report.json",
23-
"performance-test": "npm run build && npm run lighthouse"
23+
"performance-test": "npm run build && npm run lighthouse",
24+
"test:a11y": "jest src/test/accessibility.test.tsx",
25+
"audit:a11y": "jest src/test/accessibility.test.tsx --ci --forceExit"
2426
},
2527
"dependencies": {
2628
"@creit.tech/stellar-wallets-kit": "^1.9.5",
@@ -68,6 +70,7 @@
6870
"zod": "^4.4.3"
6971
},
7072
"devDependencies": {
73+
"@ducanh2912/next-pwa": "^10.2.9",
7174
"@next/bundle-analyzer": "^14.0.0",
7275
"@sentry/webpack-plugin": "^2.10.2",
7376
"@stellar/freighter-api": "^5.0.0",

frontend/src/app/admin/layout.tsx

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import type { Metadata } from 'next';
2-
import { Inter } from 'next/font/google';
32
import AdminSidebar from '@/components/Admin/AdminSidebar';
43
import AdminHeader from '@/components/Admin/AdminHeader';
54
import { AuthProvider } from '@/contexts/AuthContext';
65

7-
const inter = Inter({ subsets: ['latin'] });
8-
96
export const metadata: Metadata = {
107
title: 'Admin Panel - StarkEd Education',
118
description: 'Administrative interface for StarkEd platform management',
@@ -16,34 +13,42 @@ export default function AdminLayout({
1613
}: {
1714
children: React.ReactNode;
1815
}) {
19-
// NOTE: This segment intentionally does NOT render its own <main>. The root
20-
// App-Router layout (`app/layout.tsx`) provides the single canonical
21-
// <main id="main-content"> landmark. Adding a second <main> here would
22-
// produce invalid HTML and trip axe-core's `landmark-unique` rule.
23-
// We attach `role="region"` + aria-label so admin screen-reader users get a
24-
// labelled sub-region within the main landmark.
16+
// NOTE: This segment intentionally does NOT render its own <html> or <body>.
17+
// The root App-Router layout (app/layout.tsx) owns those singletons.
18+
// Nesting <html>/<body> here would produce invalid HTML and cause axe-core
19+
// `landmark-unique` and `duplicate-id` violations.
2520
return (
26-
<html lang="en">
27-
<body className={inter.className}>
28-
<AuthProvider>
29-
<div className="min-h-screen bg-gray-50">
30-
<div className="flex">
31-
<AdminSidebar />
32-
<div className="flex-1">
33-
<AdminHeader />
34-
<section
35-
id="admin-content-region"
36-
aria-label="Admin content"
37-
tabIndex={-1}
38-
className="focus:outline-none p-6"
39-
>
40-
{children}
41-
</section>
42-
</div>
43-
</div>
21+
<AuthProvider>
22+
{/*
23+
* Skip link scoped to the admin area — lets keyboard users jump
24+
* directly past the sidebar to the admin content region.
25+
* WCAG 2.1 SC 2.4.1 (Bypass Blocks)
26+
*/}
27+
<a href="#admin-content-region" className="skip-link">
28+
Skip to admin content
29+
</a>
30+
31+
<div className="min-h-screen bg-gray-50">
32+
<div className="flex">
33+
<AdminSidebar />
34+
<div className="flex-1">
35+
<AdminHeader />
36+
{/*
37+
* role="region" + aria-label gives screen-reader users a labelled
38+
* sub-region within the root <main> landmark.
39+
* tabIndex={-1} allows the skip link to programmatically focus here.
40+
*/}
41+
<section
42+
id="admin-content-region"
43+
aria-label="Admin content"
44+
tabIndex={-1}
45+
className="focus:outline-none p-6"
46+
>
47+
{children}
48+
</section>
4449
</div>
45-
</AuthProvider>
46-
</body>
47-
</html>
50+
</div>
51+
</div>
52+
</AuthProvider>
4853
);
4954
}

frontend/src/app/layout.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import type { Metadata } from 'next';
22
import { Inter } from 'next/font/google';
33
import { ThemeProvider } from 'next-themes';
44
import './globals.css';
5-
import { performanceMonitor } from '@/lib/performance-monitor';
65
import { GlobalShell } from '@/components/PWA/GlobalShell';
76
import { CommandPalette } from '@/components/ui/command-palette';
7+
import RouteAnnouncer from '@/components/accessibility/RouteAnnouncer';
88

99
const inter = Inter({ subsets: ['latin'] });
1010

@@ -34,6 +34,16 @@ export default function RootLayout({
3434
*/
3535
<html lang={locale} dir={dir} suppressHydrationWarning>
3636
<body className={inter.className}>
37+
{/*
38+
* Skip-to-content link — first focusable element on the page.
39+
* The .skip-link class in styles/globals.css hides it off-screen
40+
* until focused, then slides it into view.
41+
* WCAG 2.1 SC 2.4.1 (Bypass Blocks)
42+
*/}
43+
<a href="#main-content" className="skip-link">
44+
Skip to main content
45+
</a>
46+
3747
{/*
3848
* ThemeProvider configuration:
3949
* attribute="class" → Tailwind darkMode: 'class' strategy
@@ -49,6 +59,11 @@ export default function RootLayout({
4959
storageKey="starked-theme"
5060
disableTransitionOnChange={false}
5161
>
62+
{/*
63+
* RouteAnnouncer — announces navigation changes to screen readers.
64+
* WCAG 2.1 SC 4.1.3 (Status Messages)
65+
*/}
66+
<RouteAnnouncer />
5267
<GlobalShell />
5368
{children}
5469
</ThemeProvider>

frontend/src/app/profile/page.tsx

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,16 @@ export default function ProfilePage() {
8787
}
8888

8989
return (
90-
<div className="min-h-screen bg-gray-50 dark:bg-slate-900">
91-
{/* Edit Profile Modal */}
90+
<main id="main-content" className="min-h-screen bg-gray-50 dark:bg-slate-900">
91+
{/* Edit Profile Modal — role="dialog" and aria-modal trap focus for screen readers */}
9292
{showEditModal && (
93-
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
93+
<div
94+
role="dialog"
95+
aria-modal="true"
96+
aria-label="Edit profile"
97+
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
98+
onKeyDown={(e) => e.key === 'Escape' && setShowEditModal(false)}
99+
>
94100
<div className="bg-white dark:bg-slate-900 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
95101
<ProfileEditor
96102
onClose={() => setShowEditModal(false)}
@@ -118,15 +124,19 @@ export default function ProfilePage() {
118124
</div>
119125
</div>
120126

121-
{/* Navigation Tabs */}
127+
{/* Navigation Tabs — WCAG 2.1 SC 4.1.2: tablist pattern */}
122128
<div className="bg-white dark:bg-slate-900 border-b border-gray-200 dark:border-slate-700 sticky top-0 z-40">
123129
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
124-
<div className="flex space-x-8">
130+
<div className="flex space-x-8" role="tablist" aria-label="Profile sections">
125131
{tabs.map((tab) => {
126132
const Icon = tab.icon;
127133
return (
128134
<button
129135
key={tab.id}
136+
role="tab"
137+
id={`tab-${tab.id}`}
138+
aria-selected={activeTab === tab.id}
139+
aria-controls={`tabpanel-${tab.id}`}
130140
onClick={() => setActiveTab(tab.id)}
131141
className={`
132142
flex items-center gap-2 px-1 py-4 border-b-2 transition-colors
@@ -136,7 +146,7 @@ export default function ProfilePage() {
136146
}
137147
`}
138148
>
139-
<Icon className="h-4 w-4" />
149+
<Icon className="h-4 w-4" aria-hidden="true" />
140150
<span className="font-medium">{tab.label}</span>
141151
</button>
142152
);
@@ -149,6 +159,7 @@ export default function ProfilePage() {
149159
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
150160
{/* Overview Tab */}
151161
{activeTab === 'overview' && (
162+
<div role="tabpanel" id="tabpanel-overview" aria-labelledby="tab-overview" tabIndex={0}>
152163
<ErrorBoundary>
153164
<div className="space-y-8">
154165
{/* Quick Stats */}
@@ -187,10 +198,12 @@ export default function ProfilePage() {
187198
</div>
188199
</div>
189200
</ErrorBoundary>
201+
</div>
190202
)}
191203

192204
{/* Achievements Tab */}
193205
{activeTab === 'achievements' && (
206+
<div role="tabpanel" id="tabpanel-achievements" aria-labelledby="tab-achievements" tabIndex={0}>
194207
<ErrorBoundary>
195208
<AchievementDisplay
196209
achievements={achievements || []}
@@ -199,10 +212,12 @@ export default function ProfilePage() {
199212
searchable={true}
200213
/>
201214
</ErrorBoundary>
215+
</div>
202216
)}
203217

204218
{/* Credentials Tab */}
205219
{activeTab === 'credentials' && (
220+
<div role="tabpanel" id="tabpanel-credentials" aria-labelledby="tab-credentials" tabIndex={0}>
206221
<ErrorBoundary>
207222
<CredentialList
208223
credentials={credentials || []}
@@ -211,21 +226,25 @@ export default function ProfilePage() {
211226
searchable={true}
212227
/>
213228
</ErrorBoundary>
229+
</div>
214230
)}
215231

216232
{/* Statistics Tab */}
217233
{activeTab === 'stats' && (
234+
<div role="tabpanel" id="tabpanel-stats" aria-labelledby="tab-stats" tabIndex={0}>
218235
<ErrorBoundary>
219236
{stats && <ProfileStats
220237
stats={stats}
221238
showRanking={true}
222239
showProgress={true}
223240
/>}
224241
</ErrorBoundary>
242+
</div>
225243
)}
226244

227245
{/* Settings Tab */}
228246
{activeTab === 'settings' && (
247+
<div role="tabpanel" id="tabpanel-settings" aria-labelledby="tab-settings" tabIndex={0}>
229248
<ErrorBoundary>
230249
<div className="bg-white dark:bg-slate-900 rounded-lg border border-gray-200 dark:border-slate-700 p-6">
231250
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
@@ -237,18 +256,23 @@ export default function ProfilePage() {
237256
</p>
238257
</div>
239258
</ErrorBoundary>
259+
</div>
240260
)}
241261
</div>
242262

243263
{/* Loading Overlay */}
244264
{loading && (
245-
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
265+
<div
266+
role="status"
267+
aria-live="polite"
268+
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
269+
>
246270
<div className="bg-white dark:bg-slate-900 rounded-lg p-6 flex items-center gap-3">
247-
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
271+
<Loader2 className="h-6 w-6 animate-spin text-blue-600" aria-hidden="true" />
248272
<span className="text-gray-900 dark:text-white">Updating...</span>
249273
</div>
250274
</div>
251275
)}
252-
</div>
276+
</main>
253277
);
254278
}

0 commit comments

Comments
 (0)