Skip to content

Commit 26d6cfb

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 5464425 commit 26d6cfb

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
@@ -21,6 +21,7 @@ import { useRegisterSW } from "virtual:pwa-register/react";
2121
import { estimateCalories, getSavedUserWeight } from "./utils/calorieEstimator";
2222
import { CursorGlow } from "./components/CursorGlow";
2323
import { PageErrorBoundary } from "./components/PageErrorBoundary";
24+
import { ExitConfirmModal } from "./components/ExitConfirmModal";
2425
const WelcomeScreen = lazy(() => import("./components/WelcomeScreen").then(m => ({ default: m.WelcomeScreen })));
2526
const SummaryScreen = lazy(() => import("./components/SummaryScreen").then(m => ({ default: m.SummaryScreen })));
2627
const TrophyRoom = lazy(() => import("./components/TrophyRoom").then(m => ({ default: m.TrophyRoom })));
@@ -462,79 +463,17 @@ function App() {
462463
</div>
463464
)}
464465
{showExitModal && (
465-
<div
466-
style={{
467-
position: "fixed",
468-
top: 0,
469-
left: 0,
470-
width: "100%",
471-
height: "100%",
472-
background: "rgba(0,0,0,0.5)",
473-
display: "flex",
474-
justifyContent: "center",
475-
alignItems: "center",
476-
zIndex: 999,
477-
backdropFilter: "blur(8px)",
466+
<ExitConfirmModal
467+
message="Are you sure you want to end your session?"
468+
onStay={() => setShowExitModal(false)}
469+
onExit={() => {
470+
setShowExitModal(false);
471+
if (user?.uid) {
472+
localStorage.removeItem(`spectrax_telemetry_snapshot_${user.uid}`);
473+
}
474+
navigateTo('welcome');
478475
}}
479-
>
480-
<div
481-
style={{
482-
background: "rgba(255,255,255,0.1)",
483-
border: "1px solid rgba(255,255,255,0.2)",
484-
borderRadius: "20px",
485-
padding: "30px",
486-
width: "320px",
487-
textAlign: "center",
488-
color: "white",
489-
backdropFilter: "blur(15px)",
490-
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
491-
}}
492-
>
493-
<h2>Confirm Exit</h2>
494-
495-
<p>Are you sure you want to end your session?</p>
496-
497-
<div
498-
style={{
499-
display: "flex",
500-
justifyContent: "space-between",
501-
marginTop: "20px",
502-
}}
503-
>
504-
<button
505-
onClick={() => setShowExitModal(false)}
506-
style={{
507-
padding: "10px 20px",
508-
borderRadius: "10px",
509-
border: "none",
510-
cursor: "pointer",
511-
}}
512-
>
513-
Stay
514-
</button>
515-
516-
<button
517-
onClick={() => {
518-
setShowExitModal(false);
519-
if (user?.uid) {
520-
localStorage.removeItem(`spectrax_telemetry_snapshot_${user.uid}`);
521-
}
522-
navigateTo('welcome');
523-
}}
524-
style={{
525-
padding: '10px 20px',
526-
borderRadius: '10px',
527-
border: 'none',
528-
cursor: 'pointer',
529-
background: '#ff4d4f',
530-
color: 'white'
531-
}}
532-
>
533-
Exit
534-
</button>
535-
</div>
536-
</div>
537-
</div>
476+
/>
538477
)}
539478
</main>
540479
);
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
@@ -15,6 +15,7 @@ import { BodyType } from '../services/bodyTypeEngine';
1515
import { initialSquatDepthStats } from '../services/Squat_depth_classifier';
1616
import { useWorkoutSync } from '../hooks/useWorkoutSync';
1717
import { useDisplayConfig } from '../hooks/useDisplayConfig';
18+
import { ExitConfirmModal } from './ExitConfirmModal';
1819
import { useWorkoutWebSocket } from '../hooks/useWorkoutWebSocket';
1920
import { useOffscreenCanvas } from '../hooks/useOffscreenCanvas';
2021
import { injuryRiskEngine } from '../services/injuryRiskEngine';
@@ -1889,59 +1890,11 @@ export const WorkoutScreen: React.FC<WorkoutScreenProps> = ({ exercise, onEnd, o
18891890
`}</style>
18901891

18911892
{showExitModal && (
1892-
<div
1893-
style={{
1894-
position: 'fixed',
1895-
top: 0,
1896-
left: 0,
1897-
width: '100%',
1898-
height: '100%',
1899-
background: 'rgba(0,0,0,0.6)',
1900-
display: 'flex',
1901-
justifyContent: 'center',
1902-
alignItems: 'center',
1903-
zIndex: 999,
1904-
backdropFilter: 'blur(8px)'
1905-
}}
1906-
>
1907-
<div
1908-
style={{
1909-
background: 'var(--bg-card)',
1910-
border: '1px solid rgba(255,255,255,0.2)',
1911-
borderRadius: '20px',
1912-
padding: '30px',
1913-
width: '320px',
1914-
textAlign: 'center',
1915-
color: 'white',
1916-
boxShadow: '0 8px 32px rgba(0,0,0,0.3)'
1917-
}}
1918-
>
1919-
<h2>Confirm Exit</h2>
1920-
<p>Are you sure you want to end your workout session?</p>
1921-
<div
1922-
style={{
1923-
display: 'flex',
1924-
justifyContent: 'center',
1925-
gap: '20px',
1926-
marginTop: '20px'
1927-
}}
1928-
>
1929-
<button
1930-
className="btn-neon"
1931-
onClick={() => setShowExitModal(false)}
1932-
>
1933-
Stay
1934-
</button>
1935-
<button
1936-
className="btn-neon"
1937-
style={{ background: 'var(--neon-red)' }}
1938-
onClick={handleEnd}
1939-
>
1940-
Exit
1941-
</button>
1942-
</div>
1943-
</div>
1944-
</div>
1893+
<ExitConfirmModal
1894+
message="Are you sure you want to end your workout session?"
1895+
onStay={() => setShowExitModal(false)}
1896+
onExit={handleEnd}
1897+
/>
19451898
)}
19461899
</CameraErrorBoundary>
19471900
</div>

0 commit comments

Comments
 (0)