fix(17): WR-01 trap focus in modal dialogs via useFocusTrap

This commit is contained in:
Lucas Berger
2026-06-18 13:41:33 -04:00
parent a4a7438641
commit fb30800e9a
3 changed files with 28 additions and 0 deletions
@@ -32,6 +32,7 @@ import {
type SaveMyCredentialPayload,
} from '../api/client.js';
import { useIsPhone } from '../hooks/useIsPhone.js';
import { useFocusTrap } from '../hooks/useFocusTrap.js';
export type CredentialSheetMode = 'admin-rotate' | 'admin-add' | 'self-service';
@@ -83,6 +84,9 @@ export function CredentialSheet({
const [validationError, setValidationError] = useState<string | null>(null);
// Focus the heading/first focusable element on open (a11y)
const headingRef = useRef<HTMLHeadingElement>(null);
// WR-01: trap Tab/Shift+Tab inside the dialog (matches aria-modal="true").
const dialogRef = useRef<HTMLDivElement>(null);
const handleDialogKeyDown = useFocusTrap(dialogRef);
// Escape key closes the sheet (SettingsSheet pattern)
useEffect(() => {
@@ -172,9 +176,11 @@ export function CredentialSheet({
{/* Sheet */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={heading}
onKeyDown={handleDialogKeyDown}
style={
phone
? {
+16
View File
@@ -27,6 +27,7 @@ import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { usePushSubscription } from '../hooks/usePushSubscription.js';
import { useIsPhone } from '../hooks/useIsPhone.js';
import { useFocusTrap } from '../hooks/useFocusTrap.js';
import { InstructionSheet } from './InstructionSheet.js';
import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc, fetchLocalLogout } from '../api/client.js';
@@ -89,6 +90,9 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
// tap handler has ZERO awaits between the user gesture and pushManager.subscribe().
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
// WR-01: trap Tab/Shift+Tab inside the dialog (matches aria-modal="true").
const dialogRef = useRef<HTMLDivElement>(null);
const handleDialogKeyDown = useFocusTrap(dialogRef);
// Compute initial toggle on/off state per UI-SPEC toggle initial state rule:
// on when notificationsEnabled !== '0' AND permission === 'granted' AND isSubscribed
@@ -198,9 +202,11 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
{/* Sheet */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Settings"
onKeyDown={handleDialogKeyDown}
style={
phone
? {
@@ -596,6 +602,9 @@ function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
// WR-01: trap Tab/Shift+Tab inside the dialog (matches aria-modal="true").
const dialogRef = useRef<HTMLDivElement>(null);
const handleDialogKeyDown = useFocusTrap(dialogRef);
useEffect(() => {
if (!isOpen) return;
@@ -665,9 +674,11 @@ function ChangePasswordSheet({ isOpen, onClose }: ChangePasswordSheetProps) {
{/* Sheet */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Change password"
onKeyDown={handleDialogKeyDown}
style={
phone
? {
@@ -924,6 +935,9 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
const phone = useIsPhone();
const [error, setError] = useState<string | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
// WR-01: trap Tab/Shift+Tab inside the dialog (matches aria-modal="true").
const dialogRef = useRef<HTMLDivElement>(null);
const handleDialogKeyDown = useFocusTrap(dialogRef);
useEffect(() => {
if (!isOpen) return;
@@ -976,9 +990,11 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
{/* Sheet */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Link OIDC identity"
onKeyDown={handleDialogKeyDown}
style={
phone
? {
+6
View File
@@ -39,6 +39,7 @@ import {
} from '../api/client.js';
import { CredentialSheet, type CredentialSheetMode } from '../components/CredentialSheet.js';
import { useIsPhone } from '../hooks/useIsPhone.js';
import { useFocusTrap } from '../hooks/useFocusTrap.js';
// ── Styles ─────────────────────────────────────────────────────────────────
@@ -1352,6 +1353,9 @@ function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswor
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
// WR-01: trap Tab/Shift+Tab inside the dialog (matches aria-modal="true").
const dialogRef = useRef<HTMLDivElement>(null);
const handleDialogKeyDown = useFocusTrap(dialogRef);
// Escape closes the sheet
useEffect(() => {
@@ -1417,9 +1421,11 @@ function ResetPasswordSheet({ isOpen, onClose, onSuccess, member }: ResetPasswor
{/* Sheet — phone: bottom-sheet / desktop: centered modal (D-09) */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Reset password"
onKeyDown={handleDialogKeyDown}
style={
sheetPhone
? {