22 * Reconnection Modal Component
33 *
44 * Displays reconnection progress to users with cancellation option
5+ * Includes proper focus management and accessibility features
56 */
67
7- import React from 'react' ;
8+ import React , { useEffect , useRef } from 'react' ;
89
910/**
10- * Reconnection progress modal
11+ * Trap focus within modal for accessibility
12+ * @param {HTMLElement } element - Modal element to trap focus within
13+ */
14+ const trapFocus = ( element ) => {
15+ const focusableElements = element . querySelectorAll (
16+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
17+ ) ;
18+ const firstElement = focusableElements [ 0 ] ;
19+ const lastElement = focusableElements [ focusableElements . length - 1 ] ;
20+
21+ const handleTabKey = ( e ) => {
22+ if ( e . key !== 'Tab' ) return ;
23+
24+ if ( e . shiftKey ) {
25+ if ( document . activeElement === firstElement ) {
26+ lastElement . focus ( ) ;
27+ e . preventDefault ( ) ;
28+ }
29+ } else {
30+ if ( document . activeElement === lastElement ) {
31+ firstElement . focus ( ) ;
32+ e . preventDefault ( ) ;
33+ }
34+ }
35+ } ;
36+
37+ element . addEventListener ( 'keydown' , handleTabKey ) ;
38+ return ( ) => element . removeEventListener ( 'keydown' , handleTabKey ) ;
39+ } ;
40+
41+ /**
42+ * Reconnection progress modal with accessibility features
1143 * @param {Object } props - Component props
1244 * @param {boolean } props.isVisible - Whether modal is visible
1345 * @param {Object } props.progress - Reconnection progress state
1446 * @param {Function } props.onCancel - Cancel callback
1547 */
1648export const ReconnectionModal = ( { isVisible, progress, onCancel } ) => {
49+ const modalRef = useRef ( null ) ;
50+ const previousActiveElementRef = useRef ( null ) ;
51+
52+ // Focus management
53+ useEffect ( ( ) => {
54+ if ( isVisible && modalRef . current ) {
55+ // Store currently focused element
56+ previousActiveElementRef . current = document . activeElement ;
57+
58+ // Focus the modal container
59+ modalRef . current . focus ( ) ;
60+
61+ // Set up focus trap
62+ const removeFocusTrap = trapFocus ( modalRef . current ) ;
63+
64+ // Prevent background scrolling
65+ document . body . style . overflow = 'hidden' ;
66+
67+ return ( ) => {
68+ // Cleanup
69+ removeFocusTrap ( ) ;
70+ document . body . style . overflow = '' ;
71+
72+ // Restore previous focus
73+ if ( previousActiveElementRef . current ) {
74+ previousActiveElementRef . current . focus ( ) ;
75+ }
76+ } ;
77+ }
78+ } , [ isVisible ] ) ;
79+
80+ // Handle escape key
81+ useEffect ( ( ) => {
82+ const handleEscape = ( e ) => {
83+ if ( e . key === 'Escape' && isVisible && progress . canCancel ) {
84+ onCancel ( ) ;
85+ }
86+ } ;
87+
88+ if ( isVisible ) {
89+ document . addEventListener ( 'keydown' , handleEscape ) ;
90+ return ( ) => document . removeEventListener ( 'keydown' , handleEscape ) ;
91+ }
92+ } , [ isVisible , progress . canCancel , onCancel ] ) ;
93+
1794 if ( ! isVisible ) return null ;
1895
1996 const { attempt, maxAttempts, nextRetryIn, canCancel } = progress ;
@@ -27,12 +104,22 @@ export const ReconnectionModal = ({ isVisible, progress, onCancel }) => {
27104 const strokeDashoffset = circumference - ( progressPercentage / 100 ) * circumference ;
28105
29106 return (
30- < div className = "reconnection-modal" >
31- < div className = "modal-content" >
107+ < div
108+ className = "reconnection-modal"
109+ role = "dialog"
110+ aria-modal = "true"
111+ aria-labelledby = "reconnection-title"
112+ aria-describedby = "reconnection-description"
113+ >
114+ < div
115+ className = "modal-content"
116+ ref = { modalRef }
117+ tabIndex = { - 1 }
118+ >
32119 { nextRetryIn > 0 ? (
33120 < >
34121 { /* Countdown display */ }
35- < div className = "progress-ring" >
122+ < div className = "progress-ring" aria-hidden = "true" >
36123 < svg width = "80" height = "80" className = "transform -rotate-90" >
37124 < circle
38125 cx = "40"
@@ -50,32 +137,34 @@ export const ReconnectionModal = ({ isVisible, progress, onCancel }) => {
50137 />
51138 </ svg >
52139 < div className = "absolute inset-0 flex items-center justify-center" >
53- < span className = "text-2xl font-bold text-gray-700" > { nextRetryIn } </ span >
140+ < span className = "text-2xl font-bold text-gray-700" aria-live = "polite" >
141+ { nextRetryIn }
142+ </ span >
54143 </ div >
55144 </ div >
56145
57- < h3 className = "text-lg font-semibold text-gray-900 mb-2" >
146+ < h3 id = "reconnection-title" className = "text-lg font-semibold text-gray-900 mb-2" >
58147 Connection Lost
59148 </ h3 >
60- < p className = "text-gray-600 mb-4" >
149+ < p id = "reconnection-description" className = "text-gray-600 mb-4" >
61150 Attempting to reconnect in { nextRetryIn } second{ nextRetryIn !== 1 ? 's' : '' } ...
62151 </ p >
63- < p className = "text-sm text-gray-500 mb-6" >
152+ < p className = "text-sm text-gray-500 mb-6" aria-live = "polite" >
64153 Attempt { attempt } of { maxAttempts }
65154 </ p >
66155 </ >
67156 ) : (
68157 < >
69158 { /* Connecting display */ }
70- < div className = "spinner" > </ div >
159+ < div className = "spinner" aria-hidden = "true" > </ div >
71160
72- < h3 className = "text-lg font-semibold text-gray-900 mb-2" >
161+ < h3 id = "reconnection-title" className = "text-lg font-semibold text-gray-900 mb-2" >
73162 Reconnecting...
74163 </ h3 >
75- < p className = "text-gray-600 mb-4" >
164+ < p id = "reconnection-description" className = "text-gray-600 mb-4" >
76165 Attempting to restore connection
77166 </ p >
78- < p className = "text-sm text-gray-500 mb-6" >
167+ < p className = "text-sm text-gray-500 mb-6" aria-live = "polite" >
79168 Attempt { attempt } of { maxAttempts }
80169 </ p >
81170 </ >
@@ -85,13 +174,15 @@ export const ReconnectionModal = ({ isVisible, progress, onCancel }) => {
85174 < div className = "flex gap-3 justify-center" >
86175 < button
87176 onClick = { onCancel }
88- className = "px-4 py-2 text-gray-600 hover:text-gray-800 font-medium"
177+ className = "px-4 py-2 text-gray-600 hover:text-gray-800 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded"
178+ aria-label = "Cancel reconnection"
89179 >
90180 Cancel
91181 </ button >
92182 < button
93183 onClick = { ( ) => window . location . reload ( ) }
94- className = "px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium"
184+ className = "px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
185+ aria-label = "Refresh page to retry connection"
95186 >
96187 Refresh Page
97188 </ button >
@@ -102,7 +193,8 @@ export const ReconnectionModal = ({ isVisible, progress, onCancel }) => {
102193 < div className = "flex gap-3 justify-center" >
103194 < button
104195 onClick = { ( ) => window . location . reload ( ) }
105- className = "px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 font-medium"
196+ className = "px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 font-medium focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
197+ aria-label = "Refresh page - all reconnection attempts failed"
106198 >
107199 Refresh Page
108200 </ button >
@@ -113,4 +205,4 @@ export const ReconnectionModal = ({ isVisible, progress, onCancel }) => {
113205 ) ;
114206} ;
115207
116- export default ReconnectionModal ;
208+ export default ReconnectionModal ;
0 commit comments