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