From de859e54326407e6b776ebfa43491df43f75ed79 Mon Sep 17 00:00:00 2001 From: Adarsh Singh Date: Mon, 22 Jun 2026 20:02:24 +0530 Subject: [PATCH] fix: make the exit confirmation an accessible dialog The exit-confirm modal (duplicated in App and WorkoutScreen) was plain divs: no dialog role, no focus management or trap, no Escape to close, and App's "Stay" button had no styling so it rendered with low contrast. Extract a reusable ExitConfirmModal with role="dialog", aria-modal, and aria-labelledby; move focus to the safe "Stay" action on open, trap Tab within the dialog, close on Escape, and restore focus to the trigger on close. Use the app's btn-outline/btn-neon classes so both buttons have adequate contrast. Render it from both App and WorkoutScreen. --- src/App.tsx | 83 ++++------------------------ src/components/ExitConfirmModal.tsx | 84 +++++++++++++++++++++++++++++ src/components/WorkoutScreen.tsx | 59 +++----------------- 3 files changed, 101 insertions(+), 125 deletions(-) create mode 100644 src/components/ExitConfirmModal.tsx diff --git a/src/App.tsx b/src/App.tsx index a5ae85a..c2dff24 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import { useRegisterSW } from "virtual:pwa-register/react"; import { estimateCalories, getSavedUserWeight } from "./utils/calorieEstimator"; import { CursorGlow } from "./components/CursorGlow"; import { PageErrorBoundary } from "./components/PageErrorBoundary"; +import { ExitConfirmModal } from "./components/ExitConfirmModal"; const WelcomeScreen = lazy(() => import("./components/WelcomeScreen").then(m => ({ default: m.WelcomeScreen }))); const SummaryScreen = lazy(() => import("./components/SummaryScreen").then(m => ({ default: m.SummaryScreen }))); const TrophyRoom = lazy(() => import("./components/TrophyRoom").then(m => ({ default: m.TrophyRoom }))); @@ -488,79 +489,17 @@ function App() { )} {showExitModal && ( -
setShowExitModal(false)} + onExit={() => { + setShowExitModal(false); + if (user?.uid) { + localStorage.removeItem(`spectrax_telemetry_snapshot_${user.uid}`); + } + navigateTo('welcome'); }} - > -
-

Confirm Exit

- -

Are you sure you want to end your session?

- -
- - - -
-
-
+ /> )} ); diff --git a/src/components/ExitConfirmModal.tsx b/src/components/ExitConfirmModal.tsx new file mode 100644 index 0000000..6a40b14 --- /dev/null +++ b/src/components/ExitConfirmModal.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useRef, useId } from 'react'; + +interface ExitConfirmModalProps { + message: string; + onStay: () => void; + onExit: () => void; +} + +export const ExitConfirmModal: React.FC = ({ message, onStay, onExit }) => { + const dialogRef = useRef(null); + const stayButtonRef = useRef(null); + const onStayRef = useRef(onStay); + onStayRef.current = onStay; + const titleId = useId(); + + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + stayButtonRef.current?.focus(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onStayRef.current(); + return; + } + if (e.key !== 'Tab') return; + const focusable = dialogRef.current?.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + if (!focusable || focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + previouslyFocused?.focus?.(); + }; + }, []); + + return ( +
+
+

Confirm Exit

+

{message}

+
+ + +
+
+
+ ); +}; + +export default ExitConfirmModal; diff --git a/src/components/WorkoutScreen.tsx b/src/components/WorkoutScreen.tsx index 6fbaa0a..29422ba 100644 --- a/src/components/WorkoutScreen.tsx +++ b/src/components/WorkoutScreen.tsx @@ -18,6 +18,7 @@ import { initialSquatDepthStats } from '../services/Squat_depth_classifier'; import { useWorkoutSync } from '../hooks/useWorkoutSync'; import { useDisplayConfig } from '../hooks/useDisplayConfig'; import { audioFeedbackService } from '../services/audioFeedbackService'; +import { ExitConfirmModal } from './ExitConfirmModal'; import { useWorkoutWebSocket } from '../hooks/useWorkoutWebSocket'; import { useOffscreenCanvas } from '../hooks/useOffscreenCanvas'; import { injuryRiskEngine } from '../services/injuryRiskEngine'; @@ -1924,59 +1925,11 @@ export const WorkoutScreen: React.FC = ({ exercise, onEnd, o `} {showExitModal && ( -
-
-

Confirm Exit

-

Are you sure you want to end your workout session?

-
- - -
-
-
+ setShowExitModal(false)} + onExit={handleEnd} + /> )}