Skip to content

Commit de859e5

Browse files
committed
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.
1 parent 824093a commit de859e5

3 files changed

Lines changed: 101 additions & 125 deletions

File tree

src/App.tsx

Lines changed: 11 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { useRegisterSW } from "virtual:pwa-register/react";
2424
import { estimateCalories, getSavedUserWeight } from "./utils/calorieEstimator";
2525
import { CursorGlow } from "./components/CursorGlow";
2626
import { PageErrorBoundary } from "./components/PageErrorBoundary";
27+
import { ExitConfirmModal } from "./components/ExitConfirmModal";
2728
const WelcomeScreen = lazy(() => import("./components/WelcomeScreen").then(m => ({ default: m.WelcomeScreen })));
2829
const SummaryScreen = lazy(() => import("./components/SummaryScreen").then(m => ({ default: m.SummaryScreen })));
2930
const TrophyRoom = lazy(() => import("./components/TrophyRoom").then(m => ({ default: m.TrophyRoom })));
@@ -488,79 +489,17 @@ function App() {
488489
</div>
489490
)}
490491
{showExitModal && (
491-
<div
492-
style={{
493-
position: "fixed",
494-
top: 0,
495-
left: 0,
496-
width: "100%",
497-
height: "100%",
498-
background: "rgba(0,0,0,0.5)",
499-
display: "flex",
500-
justifyContent: "center",
501-
alignItems: "center",
502-
zIndex: 999,
503-
backdropFilter: "blur(8px)",
492+
<ExitConfirmModal
493+
message="Are you sure you want to end your session?"
494+
onStay={() => setShowExitModal(false)}
495+
onExit={() => {
496+
setShowExitModal(false);
497+
if (user?.uid) {
498+
localStorage.removeItem(`spectrax_telemetry_snapshot_${user.uid}`);
499+
}
500+
navigateTo('welcome');
504501
}}
505-
>
506-
<div
507-
style={{
508-
background: "rgba(255,255,255,0.1)",
509-
border: "1px solid rgba(255,255,255,0.2)",
510-
borderRadius: "20px",
511-
padding: "30px",
512-
width: "320px",
513-
textAlign: "center",
514-
color: "white",
515-
backdropFilter: "blur(15px)",
516-
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
517-
}}
518-
>
519-
<h2>Confirm Exit</h2>
520-
521-
<p>Are you sure you want to end your session?</p>
522-
523-
<div
524-
style={{
525-
display: "flex",
526-
justifyContent: "space-between",
527-
marginTop: "20px",
528-
}}
529-
>
530-
<button
531-
onClick={() => setShowExitModal(false)}
532-
style={{
533-
padding: "10px 20px",
534-
borderRadius: "10px",
535-
border: "none",
536-
cursor: "pointer",
537-
}}
538-
>
539-
Stay
540-
</button>
541-
542-
<button
543-
onClick={() => {
544-
setShowExitModal(false);
545-
if (user?.uid) {
546-
localStorage.removeItem(`spectrax_telemetry_snapshot_${user.uid}`);
547-
}
548-
navigateTo('welcome');
549-
}}
550-
style={{
551-
padding: '10px 20px',
552-
borderRadius: '10px',
553-
border: 'none',
554-
cursor: 'pointer',
555-
background: '#ff4d4f',
556-
color: 'white'
557-
}}
558-
>
559-
Exit
560-
</button>
561-
</div>
562-
</div>
563-
</div>
502+
/>
564503
)}
565504
</main>
566505
);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import React, { useEffect, useRef, useId } from 'react';
2+
3+
interface ExitConfirmModalProps {
4+
message: string;
5+
onStay: () => void;
6+
onExit: () => void;
7+
}
8+
9+
export const ExitConfirmModal: React.FC<ExitConfirmModalProps> = ({ message, onStay, onExit }) => {
10+
const dialogRef = useRef<HTMLDivElement>(null);
11+
const stayButtonRef = useRef<HTMLButtonElement>(null);
12+
const onStayRef = useRef(onStay);
13+
onStayRef.current = onStay;
14+
const titleId = useId();
15+
16+
useEffect(() => {
17+
const previouslyFocused = document.activeElement as HTMLElement | null;
18+
stayButtonRef.current?.focus();
19+
20+
const handleKeyDown = (e: KeyboardEvent) => {
21+
if (e.key === 'Escape') {
22+
e.preventDefault();
23+
onStayRef.current();
24+
return;
25+
}
26+
if (e.key !== 'Tab') return;
27+
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
28+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
29+
);
30+
if (!focusable || focusable.length === 0) return;
31+
const first = focusable[0];
32+
const last = focusable[focusable.length - 1];
33+
if (e.shiftKey && document.activeElement === first) {
34+
e.preventDefault();
35+
last.focus();
36+
} else if (!e.shiftKey && document.activeElement === last) {
37+
e.preventDefault();
38+
first.focus();
39+
}
40+
};
41+
42+
document.addEventListener('keydown', handleKeyDown);
43+
return () => {
44+
document.removeEventListener('keydown', handleKeyDown);
45+
previouslyFocused?.focus?.();
46+
};
47+
}, []);
48+
49+
return (
50+
<div
51+
style={{
52+
position: 'fixed',
53+
top: 0,
54+
left: 0,
55+
width: '100%',
56+
height: '100%',
57+
background: 'rgba(0,0,0,0.5)',
58+
display: 'flex',
59+
justifyContent: 'center',
60+
alignItems: 'center',
61+
zIndex: 999,
62+
backdropFilter: 'blur(8px)',
63+
}}
64+
>
65+
<div
66+
ref={dialogRef}
67+
role="dialog"
68+
aria-modal="true"
69+
aria-labelledby={titleId}
70+
className="glass"
71+
style={{ padding: '30px', width: '320px', maxWidth: '90vw', textAlign: 'center' }}
72+
>
73+
<h2 id={titleId}>Confirm Exit</h2>
74+
<p>{message}</p>
75+
<div style={{ display: 'flex', justifyContent: 'center', gap: '16px', marginTop: '20px' }}>
76+
<button ref={stayButtonRef} className="btn-outline" onClick={onStay}>Stay</button>
77+
<button className="btn-neon" style={{ background: 'var(--neon-red)', color: '#fff' }} onClick={onExit}>Exit</button>
78+
</div>
79+
</div>
80+
</div>
81+
);
82+
};
83+
84+
export default ExitConfirmModal;

src/components/WorkoutScreen.tsx

Lines changed: 6 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { initialSquatDepthStats } from '../services/Squat_depth_classifier';
1818
import { useWorkoutSync } from '../hooks/useWorkoutSync';
1919
import { useDisplayConfig } from '../hooks/useDisplayConfig';
2020
import { audioFeedbackService } from '../services/audioFeedbackService';
21+
import { ExitConfirmModal } from './ExitConfirmModal';
2122
import { useWorkoutWebSocket } from '../hooks/useWorkoutWebSocket';
2223
import { useOffscreenCanvas } from '../hooks/useOffscreenCanvas';
2324
import { injuryRiskEngine } from '../services/injuryRiskEngine';
@@ -1924,59 +1925,11 @@ export const WorkoutScreen: React.FC<WorkoutScreenProps> = ({ exercise, onEnd, o
19241925
`}</style>
19251926

19261927
{showExitModal && (
1927-
<div
1928-
style={{
1929-
position: 'fixed',
1930-
top: 0,
1931-
left: 0,
1932-
width: '100%',
1933-
height: '100%',
1934-
background: 'rgba(0,0,0,0.6)',
1935-
display: 'flex',
1936-
justifyContent: 'center',
1937-
alignItems: 'center',
1938-
zIndex: 999,
1939-
backdropFilter: 'blur(8px)'
1940-
}}
1941-
>
1942-
<div
1943-
style={{
1944-
background: 'var(--bg-card)',
1945-
border: '1px solid rgba(255,255,255,0.2)',
1946-
borderRadius: '20px',
1947-
padding: '30px',
1948-
width: '320px',
1949-
textAlign: 'center',
1950-
color: 'white',
1951-
boxShadow: '0 8px 32px rgba(0,0,0,0.3)'
1952-
}}
1953-
>
1954-
<h2>Confirm Exit</h2>
1955-
<p>Are you sure you want to end your workout session?</p>
1956-
<div
1957-
style={{
1958-
display: 'flex',
1959-
justifyContent: 'center',
1960-
gap: '20px',
1961-
marginTop: '20px'
1962-
}}
1963-
>
1964-
<button
1965-
className="btn-neon"
1966-
onClick={() => setShowExitModal(false)}
1967-
>
1968-
Stay
1969-
</button>
1970-
<button
1971-
className="btn-neon"
1972-
style={{ background: 'var(--neon-red)' }}
1973-
onClick={handleEnd}
1974-
>
1975-
Exit
1976-
</button>
1977-
</div>
1978-
</div>
1979-
</div>
1928+
<ExitConfirmModal
1929+
message="Are you sure you want to end your workout session?"
1930+
onStay={() => setShowExitModal(false)}
1931+
onExit={handleEnd}
1932+
/>
19801933
)}
19811934
</CameraErrorBoundary>
19821935
</div>

0 commit comments

Comments
 (0)