11'use client' ;
22
3- import { useState , useEffect } from 'react' ;
3+ import { useState , useEffect , useRef , useCallback } from 'react' ;
44import { AnimatePresence , motion } from 'framer-motion' ;
55import { X , RefreshCw , Share2 } from 'lucide-react' ;
66import 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