Skip to content

Commit 198cd4d

Browse files
authored
fix(a11y): add focus trap and ARIA dialog semantics to Compare Profile modal (JhaSourav07#2146)
## Description Fixes JhaSourav07#2139 The Compare Profile modal in `DashboardClient.tsx` was implemented as a plain `<div>` without proper ARIA dialog semantics or keyboard focus management, violating WCAG 2.1 SC 2.4.3 (Focus Order) and SC 4.1.2 (Name, Role, Value). **Changes:** - Added `role="dialog"`, `aria-modal="true"`, and `aria-labelledby="compare-modal-title"` to the modal container - Added `id="compare-modal-title"` to the modal heading for the `aria-labelledby` reference - Implemented a keyboard focus trap (`handleModalKeyDown`) that constrains Tab/Shift+Tab within the modal - Added `ref={compareInputRef}` to auto-focus the input field when the modal opens - Added `ref={triggerRef}` to the "Compare Profile" button to restore focus when the modal closes - Added `ref={modalRef}` to the modal content for focus trap element querying ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview N/A — this is a keyboard/screen reader accessibility fix. The visual appearance is unchanged. ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [x] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [ ] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents 3254557 + fa7c9df commit 198cd4d

1 file changed

Lines changed: 68 additions & 3 deletions

File tree

components/dashboard/DashboardClient.tsx

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useState, useEffect } from 'react';
3+
import { useState, useEffect, useRef, useCallback } from 'react';
44
import { AnimatePresence, motion } from 'framer-motion';
55
import { X, RefreshCw, Share2 } from 'lucide-react';
66
import Link from 'next/link';
@@ -327,6 +327,10 @@ export default function DashboardClient({ initialData, username }: DashboardClie
327327
const [isLoadingSecond, setIsLoadingSecond] = useState(false);
328328
const [compareError, setCompareError] = useState<string | null>(null);
329329

330+
const modalRef = useRef<HTMLDivElement>(null);
331+
const compareInputRef = useRef<HTMLInputElement>(null);
332+
const triggerRef = useRef<HTMLButtonElement>(null);
333+
330334
// Close modal on escape key
331335
useEffect(() => {
332336
const handleKeyDown = (e: KeyboardEvent) => {
@@ -338,6 +342,53 @@ export default function DashboardClient({ initialData, username }: DashboardClie
338342
return () => window.removeEventListener('keydown', handleKeyDown);
339343
}, []);
340344

345+
// Focus trap: constrain Tab navigation within the modal when open
346+
const handleModalKeyDown = useCallback((e: React.KeyboardEvent) => {
347+
if (e.key !== 'Tab' || !modalRef.current) return;
348+
349+
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
350+
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]'
351+
);
352+
if (focusableElements.length === 0) return;
353+
354+
const firstElement = focusableElements[0];
355+
const lastElement = focusableElements[focusableElements.length - 1];
356+
357+
// If focus is not within the modal, force it back inside
358+
const activeEl = document.activeElement;
359+
const isFocusInModal = modalRef.current.contains(activeEl);
360+
361+
if (!isFocusInModal) {
362+
e.preventDefault();
363+
firstElement.focus();
364+
return;
365+
}
366+
367+
if (e.shiftKey) {
368+
if (activeEl === firstElement) {
369+
e.preventDefault();
370+
lastElement.focus();
371+
}
372+
} else {
373+
if (activeEl === lastElement) {
374+
e.preventDefault();
375+
firstElement.focus();
376+
}
377+
}
378+
}, []);
379+
380+
// Restore focus to trigger button when modal closes
381+
useEffect(() => {
382+
if (!isModalOpen) {
383+
triggerRef.current?.focus();
384+
}
385+
}, [isModalOpen]);
386+
387+
// Auto-focus callback for the modal animation completion
388+
const handleModalAnimationComplete = useCallback(() => {
389+
compareInputRef.current?.focus();
390+
}, []);
391+
341392
const handleOpenModal = () => {
342393
setSecondUsernameInput('');
343394
setCompareError(null);
@@ -500,6 +551,7 @@ export default function DashboardClient({ initialData, username }: DashboardClie
500551
Profile Optimizer
501552
</button>
502553
<button
554+
ref={triggerRef}
503555
onClick={handleOpenModal}
504556
className="flex items-center gap-2 rounded-xl border border-black/10 dark:border-[rgba(255,255,255,0.15)] bg-black dark:bg-[#111] hover:bg-zinc-800 dark:hover:bg-zinc-900 px-4 py-2 text-sm font-semibold text-white dark:text-white transition-all duration-200 active:scale-[0.98]"
505557
>
@@ -996,22 +1048,31 @@ export default function DashboardClient({ initialData, username }: DashboardClie
9961048
{/* Compare Modal */}
9971049
<AnimatePresence>
9981050
{isModalOpen && (
999-
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
1051+
<div
1052+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
1053+
onKeyDown={handleModalKeyDown}
1054+
>
10001055
{/* Backdrop */}
10011056
<motion.div
10021057
initial={{ opacity: 0 }}
10031058
animate={{ opacity: 1 }}
10041059
exit={{ opacity: 0 }}
10051060
onClick={() => setIsModalOpen(false)}
10061061
className="absolute inset-0 bg-black/60 backdrop-blur-md"
1062+
aria-hidden="true"
10071063
/>
10081064

10091065
{/* Modal Dialog */}
10101066
<motion.div
1067+
ref={modalRef}
1068+
role="dialog"
1069+
aria-modal="true"
1070+
aria-labelledby="compare-modal-title"
10111071
initial={{ opacity: 0, scale: 0.95, y: 16 }}
10121072
animate={{ opacity: 1, scale: 1, y: 0 }}
10131073
exit={{ opacity: 0, scale: 0.95, y: 16 }}
10141074
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
1075+
onAnimationComplete={handleModalAnimationComplete}
10151076
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-black/10 bg-white p-6 dark:border-[rgba(255,255,255,0.08)] dark:bg-[#0a0a0a] shadow-xl"
10161077
>
10171078
{/* Close Button */}
@@ -1024,7 +1085,10 @@ export default function DashboardClient({ initialData, username }: DashboardClie
10241085
</button>
10251086

10261087
<div className="mb-6">
1027-
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-1">
1088+
<h3
1089+
id="compare-modal-title"
1090+
className="text-lg font-bold text-gray-900 dark:text-white mb-1"
1091+
>
10281092
Compare Profile
10291093
</h3>
10301094
<p className="text-xs text-[#A1A1AA] leading-relaxed">
@@ -1036,6 +1100,7 @@ export default function DashboardClient({ initialData, username }: DashboardClie
10361100
<form onSubmit={handleFetchComparison} className="space-y-4">
10371101
<div className="relative flex items-center">
10381102
<input
1103+
ref={compareInputRef}
10391104
type="text"
10401105
required
10411106
disabled={isLoadingSecond}

0 commit comments

Comments
 (0)