fix(17): WR-01 trap focus in modal dialogs via useFocusTrap
This commit is contained in:
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
Reference in New Issue
Block a user