Skip to content

Commit 49b3e01

Browse files
committed
Maximum accessibility overhaul: skip link, reduced motion, ARIA fixes, external link labels, focus management, contrast, ESLint a11y, axe-core, page titles
Made-with: Cursor
1 parent 175c636 commit 49b3e01

19 files changed

Lines changed: 3846 additions & 384 deletions

eslint.config.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import js from '@eslint/js';
2+
import tseslint from 'typescript-eslint';
3+
import jsxA11y from 'eslint-plugin-jsx-a11y';
4+
5+
export default [
6+
js.configs.recommended,
7+
...tseslint.configs.recommended,
8+
{
9+
files: ['**/*.{js,jsx,ts,tsx}'],
10+
plugins: {
11+
'jsx-a11y': jsxA11y,
12+
},
13+
languageOptions: {
14+
parser: tseslint.parser,
15+
parserOptions: {
16+
ecmaVersion: 'latest',
17+
sourceType: 'module',
18+
ecmaFeatures: {
19+
jsx: true,
20+
},
21+
},
22+
globals: {
23+
window: 'readonly',
24+
document: 'readonly',
25+
console: 'readonly',
26+
module: 'readonly',
27+
require: 'readonly',
28+
process: 'readonly',
29+
__dirname: 'readonly',
30+
__filename: 'readonly',
31+
exports: 'writable',
32+
Buffer: 'readonly',
33+
setTimeout: 'readonly',
34+
clearTimeout: 'readonly',
35+
setInterval: 'readonly',
36+
clearInterval: 'readonly',
37+
fetch: 'readonly',
38+
FormData: 'readonly',
39+
URL: 'readonly',
40+
URLSearchParams: 'readonly',
41+
Headers: 'readonly',
42+
Request: 'readonly',
43+
Response: 'readonly',
44+
AbortController: 'readonly',
45+
Blob: 'readonly',
46+
File: 'readonly',
47+
navigator: 'readonly',
48+
localStorage: 'readonly',
49+
sessionStorage: 'readonly',
50+
Image: 'readonly',
51+
ResizeObserver: 'readonly',
52+
MutationObserver: 'readonly',
53+
IntersectionObserver: 'readonly',
54+
requestAnimationFrame: 'readonly',
55+
cancelAnimationFrame: 'readonly',
56+
},
57+
},
58+
rules: {
59+
...jsxA11y.configs.recommended.rules,
60+
},
61+
},
62+
];

package-lock.json

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

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"name": "Regan Maharjan Portfolio Website",
3+
"type": "module",
34
"version": "0.1.0",
45
"private": true,
56
"dependencies": {
@@ -49,14 +50,18 @@
4950
"vaul": "^1.1.2"
5051
},
5152
"devDependencies": {
53+
"@axe-core/react": "^4.11.1",
5254
"@types/node": "^20.10.0",
5355
"@types/react": "^19.2.4",
5456
"@types/react-dom": "^19.2.3",
5557
"@vitejs/plugin-react-swc": "^3.10.2",
58+
"eslint-plugin-jsx-a11y": "^6.10.2",
59+
"typescript-eslint": "^8.56.1",
5660
"vite": "6.3.5"
5761
},
5862
"scripts": {
5963
"dev": "vite",
60-
"build": "vite build"
64+
"build": "vite build",
65+
"lint": "eslint src --ext .ts,.tsx"
6166
}
6267
}

src/App.tsx

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useRef } from 'react';
2+
import { MotionConfig } from 'motion/react';
23
import { HashRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
34
import { Navigation } from './components/Navigation';
45
import { Footer } from './components/Footer';
@@ -56,11 +57,13 @@ function AppContent() {
5657
};
5758
}, [location.pathname, isDetailPage]);
5859

59-
// Disable right-click context menu on all pages
60+
// Scoped context menu prevention: only on images to avoid interfering with assistive tech
6061
useEffect(() => {
6162
const handleContextMenu = (e: MouseEvent) => {
62-
e.preventDefault();
63-
return false;
63+
const target = e.target as HTMLElement;
64+
if (target.closest('img')) {
65+
e.preventDefault();
66+
}
6467
};
6568

6669
document.addEventListener('contextmenu', handleContextMenu);
@@ -75,10 +78,49 @@ function AppContent() {
7578
preloadCriticalImages();
7679
}, []);
7780

81+
// Page-specific document titles for screen reader users
82+
const titleMap: Record<string, string> = {
83+
'/': 'Regan Maharjan Portfolio',
84+
'/about': 'About | Regan Maharjan',
85+
'/experience': 'Experience | Regan Maharjan',
86+
'/projects': 'Projects | Regan Maharjan',
87+
'/impact': 'Stories of Impact | Regan Maharjan',
88+
'/storiesofadventure': 'Stories of Adventure | Regan Maharjan',
89+
'/accessibility': 'Accessibility | Regan Maharjan',
90+
'/contact': 'Contact | Regan Maharjan',
91+
'/photography': 'Photography | Regan Maharjan',
92+
'/cms': 'Content Management | Regan Maharjan',
93+
};
94+
useEffect(() => {
95+
const segments = location.pathname.split('/').filter(Boolean);
96+
const basePath = segments[0] ? `/${segments[0]}` : '/';
97+
const title = titleMap[basePath] ?? titleMap[location.pathname] ?? 'Regan Maharjan Portfolio';
98+
document.title = title;
99+
}, [location.pathname]);
100+
101+
// Focus main content on route change (helps keyboard/screen reader users)
102+
useEffect(() => {
103+
const main = document.getElementById('main-content');
104+
main?.focus();
105+
}, [location.pathname]);
106+
78107
return (
79-
<div className="min-h-screen bg-background flex flex-col">
108+
<div className="min-h-screen bg-background flex flex-col relative">
109+
{/* Skip link: first focusable element for keyboard users */}
110+
<a
111+
href="#main-content"
112+
className="skip-link"
113+
onClick={(e) => {
114+
e.preventDefault();
115+
const main = document.getElementById('main-content');
116+
main?.focus();
117+
main?.scrollIntoView();
118+
}}
119+
>
120+
Skip to main content
121+
</a>
80122
{!isHomePage && <Navigation />}
81-
<main className="flex-1">
123+
<main id="main-content" className="flex-1" tabIndex={-1}>
82124
<Routes>
83125
<Route path="/" element={
84126
<div className="min-h-screen flex flex-col items-center justify-center bg-background">
@@ -108,8 +150,10 @@ function AppContent() {
108150

109151
export default function App() {
110152
return (
111-
<Router>
112-
<AppContent />
113-
</Router>
153+
<MotionConfig reducedMotion="user">
154+
<Router>
155+
<AppContent />
156+
</Router>
157+
</MotionConfig>
114158
);
115159
}

src/components/Navigation.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export function Navigation({ inline = false }: NavigationProps) {
1717
const topNavContainerRef = useRef<HTMLDivElement>(null);
1818
const navRef = useRef<HTMLElement>(null);
1919
const tabRefs = useRef<(HTMLAnchorElement | null)[]>([]);
20+
const mobileMenuButtonRef = useRef<HTMLButtonElement>(null);
21+
const firstMobileLinkRef = useRef<HTMLAnchorElement>(null);
2022

2123
useEffect(() => {
2224
let rafId: number | null = null;
@@ -77,6 +79,22 @@ export function Navigation({ inline = false }: NavigationProps) {
7779
}, 150);
7880
}, [location.pathname, inline]);
7981

82+
// Mobile menu focus management: focus first link when opening, return focus to button when closing
83+
const prevMobileMenuOpenRef = useRef(isMobileMenuOpen);
84+
useEffect(() => {
85+
if (inline) return;
86+
if (isMobileMenuOpen) {
87+
const timer = setTimeout(() => {
88+
firstMobileLinkRef.current?.focus();
89+
}, 100);
90+
prevMobileMenuOpenRef.current = true;
91+
return () => clearTimeout(timer);
92+
} else if (prevMobileMenuOpenRef.current) {
93+
prevMobileMenuOpenRef.current = false;
94+
mobileMenuButtonRef.current?.focus();
95+
}
96+
}, [isMobileMenuOpen, inline]);
97+
8098
const navLinks = inline ? (contentData.navigation.homeLinks || contentData.navigation.links) : contentData.navigation.links;
8199

82100
// Icon mapping for navigation links
@@ -295,6 +313,7 @@ export function Navigation({ inline = false }: NavigationProps) {
295313
{/* Mobile Menu Button - touch-friendly (min 44px) */}
296314
<div className="flex-1 flex justify-end lg:justify-center">
297315
<button
316+
ref={mobileMenuButtonRef}
298317
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
299318
className="lg:hidden min-h-[44px] min-w-[44px] flex items-center justify-center rounded-lg hover:bg-black/5 active:bg-black/10 transition-colors touch-manipulation"
300319
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
@@ -315,12 +334,13 @@ export function Navigation({ inline = false }: NavigationProps) {
315334
className="lg:hidden bg-white border-t border-gray-200 shadow-lg"
316335
>
317336
<div className="w-full max-w-[90rem] mx-auto px-4 sm:px-6 py-4 sm:py-5 space-y-2 text-center">
318-
{navLinks.map((link: { path: string; label: string }) => {
337+
{navLinks.map((link: { path: string; label: string }, index: number) => {
319338
const isActive = location.pathname === link.path;
320339
const IconComponent = getIconForPath(link.path);
321340
return (
322341
<Link
323342
key={link.path}
343+
ref={index === 0 ? firstMobileLinkRef : undefined}
324344
to={link.path}
325345
onClick={() => setIsMobileMenuOpen(false)}
326346
className={`block w-full py-4 px-6 rounded-xl transition-colors touch-manipulation min-h-[48px] flex items-center justify-center gap-2 ${

src/components/pages/About.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,25 +98,25 @@ export function About() {
9898
className="text-gray-500 hover:text-gray-900 transition-colors inline-flex items-center"
9999
aria-label="Email"
100100
>
101-
<Mail className="w-5 h-5" />
101+
<Mail className="w-5 h-5" aria-hidden />
102102
</a>
103103
<a
104104
href={contentData.assets.links.linkedin}
105105
target="_blank"
106106
rel="noopener noreferrer"
107107
className="text-gray-500 hover:text-gray-900 transition-colors inline-flex items-center"
108-
aria-label="LinkedIn"
108+
aria-label="LinkedIn (opens in new window)"
109109
>
110-
<Linkedin className="w-5 h-5" />
110+
<Linkedin className="w-5 h-5" aria-hidden />
111111
</a>
112112
<a
113113
href={contentData.assets.links.instagram}
114114
target="_blank"
115115
rel="noopener noreferrer"
116116
className="text-gray-500 hover:text-gray-900 transition-colors inline-flex items-center"
117-
aria-label="Instagram"
117+
aria-label="Instagram (opens in new window)"
118118
>
119-
<Instagram className="w-5 h-5" />
119+
<Instagram className="w-5 h-5" aria-hidden />
120120
</a>
121121
</div>
122122
<p className="text-lg md:text-xl text-gray-700 leading-relaxed mt-8">
@@ -125,7 +125,7 @@ export function About() {
125125
to="/experience"
126126
className="inline-flex items-center gap-1.5 text-gray-900 hover:text-gray-600 underline transition-colors"
127127
>
128-
<FileText className="w-4 h-4 shrink-0" />
128+
<FileText className="w-4 h-4 shrink-0" aria-hidden />
129129
resume
130130
</Link>
131131
.

src/components/pages/Accessibility.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export function Accessibility() {
7777
whileHover={{ y: -4 }}
7878
className="bg-gradient-to-b from-blue-50/50 to-white border border-blue-100 rounded-2xl p-8 transition-all duration-300 hover:shadow-lg"
7979
>
80-
<principle.icon className="w-8 h-8 mb-4 text-blue-600" />
80+
<principle.icon className="w-8 h-8 mb-4 text-blue-600" aria-hidden />
8181
<h3 className="mb-2">{principle.title}</h3>
8282
<p className="text-muted-foreground text-sm">{principle.description}</p>
8383
</motion.div>
@@ -118,7 +118,7 @@ export function Accessibility() {
118118
<ul className="space-y-2">
119119
{category.items.map((item) => (
120120
<li key={item} className="flex items-start gap-2 text-sm">
121-
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
121+
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" aria-hidden />
122122
<span className="text-muted-foreground">{item}</span>
123123
</li>
124124
))}
@@ -159,7 +159,7 @@ export function Accessibility() {
159159
>
160160
<div className="flex items-start gap-4">
161161
<div className="w-12 h-12 rounded-xl bg-blue-100 flex items-center justify-center flex-shrink-0">
162-
<Zap className="w-6 h-6 text-blue-600" />
162+
<Zap className="w-6 h-6 text-blue-600" aria-hidden />
163163
</div>
164164
<div className="flex-1">
165165
<h3 className="text-xl mb-1">{tool.name}</h3>
@@ -322,7 +322,7 @@ export function Accessibility() {
322322
Send an Email
323323
</Button>
324324
</a>
325-
<a href={contentData.assets.links.linkedin} target="_blank" rel="noopener noreferrer">
325+
<a href={contentData.assets.links.linkedin} target="_blank" rel="noopener noreferrer" aria-label="Connect on LinkedIn (opens in new window)">
326326
<Button size="lg" variant="outline" className="rounded-full px-8">
327327
Connect on LinkedIn
328328
</Button>

src/components/pages/Contact.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export function Contact() {
113113

114114
<Button type="submit" size="lg" className="w-full rounded-full group">
115115
{form.submit}
116-
<Send className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform" />
116+
<Send className="ml-2 w-4 h-4 group-hover:translate-x-1 transition-transform" aria-hidden />
117117
</Button>
118118
</form>
119119
</div>
@@ -139,14 +139,15 @@ export function Contact() {
139139
href={link.href}
140140
target="_blank"
141141
rel="noopener noreferrer"
142+
aria-label={`${link.label} (opens in new window)`}
142143
initial={{ opacity: 0, x: 30 }}
143144
animate={{ opacity: 1, x: 0 }}
144145
transition={{ duration: 0.6, delay: 0.6 + index * 0.1 }}
145146
whileHover={{ x: 4 }}
146147
className="flex items-center gap-4 p-4 surface-elevated rounded-xl transition-all duration-300 hover:shadow-md group"
147148
>
148149
<div className="w-12 h-12 rounded-xl bg-blue-100 flex items-center justify-center group-hover:bg-blue-200 transition-colors">
149-
<link.icon className="w-6 h-6 text-blue-600" />
150+
<link.icon className="w-6 h-6 text-blue-600" aria-hidden />
150151
</div>
151152
<div>
152153
<div className="mb-1">{link.label}</div>
@@ -194,7 +195,7 @@ export function Contact() {
194195
{cta.primary}
195196
</Button>
196197
</a>
197-
<a href={links.linkedin} target="_blank" rel="noopener noreferrer">
198+
<a href={links.linkedin} target="_blank" rel="noopener noreferrer" aria-label="Connect on LinkedIn (opens in new window)">
198199
<Button size="lg" variant="outline" className="rounded-full px-8">
199200
{cta.secondary}
200201
</Button>

src/components/pages/Experience.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ export function Experience() {
172172
Send an Email
173173
</Button>
174174
</a>
175-
<a href={contentData.assets.links.linkedin} target="_blank" rel="noopener noreferrer">
175+
<a href={contentData.assets.links.linkedin} target="_blank" rel="noopener noreferrer" aria-label="Connect on LinkedIn (opens in new window)">
176176
<Button size="lg" variant="outline" className="rounded-full px-8">
177177
Connect on LinkedIn
178178
</Button>

src/components/pages/Home.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,33 +44,33 @@ export function Home() {
4444
Feel free to explore!{' '}
4545
<Link
4646
to="/about"
47-
className="inline-flex items-center gap-1.5 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gray-200 hover:bg-gray-300 border-2 border-gray-300 hover:border-gray-400 transition-all font-semibold text-gray-900 shadow-sm hover:shadow-md my-1 sm:my-0"
47+
className="inline-flex items-center gap-1.5 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gray-200 hover:bg-gray-300 border-2 border-gray-300 hover:border-gray-400 transition-all font-semibold text-gray-900 shadow-sm hover:shadow-md my-1 sm:my-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
4848
>
49-
<Hand className="w-4 h-4 sm:w-5 sm:h-5" />
49+
<Hand className="w-4 h-4 sm:w-5 sm:h-5" aria-hidden />
5050
Get to know me
5151
</Link>
5252
{' '}better,{' '}
5353
<Link
5454
to="/storiesofadventure"
55-
className="inline-flex items-center gap-1.5 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gray-200 hover:bg-gray-300 border-2 border-gray-300 hover:border-gray-400 transition-all font-semibold text-gray-900 shadow-sm hover:shadow-md my-1 sm:my-0"
55+
className="inline-flex items-center gap-1.5 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gray-200 hover:bg-gray-300 border-2 border-gray-300 hover:border-gray-400 transition-all font-semibold text-gray-900 shadow-sm hover:shadow-md my-1 sm:my-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
5656
>
57-
<BookOpen className="w-4 h-4 sm:w-5 sm:h-5" />
57+
<BookOpen className="w-4 h-4 sm:w-5 sm:h-5" aria-hidden />
5858
dive into my stories
5959
</Link>
6060
,{' '}
6161
<Link
6262
to="/projects"
63-
className="inline-flex items-center gap-1.5 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gray-200 hover:bg-gray-300 border-2 border-gray-300 hover:border-gray-400 transition-all font-semibold text-gray-900 shadow-sm hover:shadow-md my-1 sm:my-0"
63+
className="inline-flex items-center gap-1.5 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gray-200 hover:bg-gray-300 border-2 border-gray-300 hover:border-gray-400 transition-all font-semibold text-gray-900 shadow-sm hover:shadow-md my-1 sm:my-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
6464
>
65-
<FolderKanban className="w-4 h-4 sm:w-5 sm:h-5" />
65+
<FolderKanban className="w-4 h-4 sm:w-5 sm:h-5" aria-hidden />
6666
check out my projects
6767
</Link>
6868
, and{' '}
6969
<Link
7070
to="/photography"
71-
className="inline-flex items-center gap-1.5 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gray-200 hover:bg-gray-300 border-2 border-gray-300 hover:border-gray-400 transition-all font-semibold text-gray-900 shadow-sm hover:shadow-md my-1 sm:my-0"
71+
className="inline-flex items-center gap-1.5 px-3 sm:px-4 py-2 sm:py-2.5 rounded-lg bg-gray-200 hover:bg-gray-300 border-2 border-gray-300 hover:border-gray-400 transition-all font-semibold text-gray-900 shadow-sm hover:shadow-md my-1 sm:my-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
7272
>
73-
<Camera className="w-4 h-4 sm:w-5 sm:h-5" />
73+
<Camera className="w-4 h-4 sm:w-5 sm:h-5" aria-hidden />
7474
browse my photo stories
7575
</Link>
7676
.

0 commit comments

Comments
 (0)