From bc34e65aace9bda183ca33360a36c2b9471726bb Mon Sep 17 00:00:00 2001 From: c0mrade Date: Tue, 26 May 2026 13:44:46 +0300 Subject: [PATCH] fix(admin-bulk-actions): trap focus + label modal dialog (a11y) Bulk-action modal had role=dialog on the overlay but no focus trap, no aria-labelledby, no scroll lock, and an ad-hoc Escape listener that conflicted with anything else listening to document keydown. Move role=dialog/aria-modal onto the focus-trapped content element, wire aria-labelledby to the h3 title, and replace the ad-hoc keydown effect with useFocusTrap (Tab cycle + Esc + body scroll lock + focus restore on close). Loading state suppresses Esc but keeps focus trapped, so Tab still cycles inside the progress view. --- src/pages/AdminBulkActions.tsx | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 578f215..15116da 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -24,6 +24,7 @@ import { } from '../api/adminBulkActions'; import { usePlatform } from '../platform/hooks/usePlatform'; import { useCurrency } from '../hooks/useCurrency'; +import { useFocusTrap } from '@/hooks/useFocusTrap'; import { cn } from '@/lib/utils'; // ============ Types ============ @@ -802,16 +803,10 @@ function ActionModal({ }, [modal.open, modal.action]); // Escape key handler — only when not loading - useEffect(() => { - if (!modal.open) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && !modal.loading) { - onClose(); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [modal.open, modal.loading, onClose]); + // Focus trap + scroll lock + Escape close (only while not loading). + const dialogRef = useFocusTrap(modal.open, { + onEscape: modal.loading ? undefined : onClose, + }); // Count active paid subscriptions among selected ones const activePaidCount = useMemo(() => { @@ -1118,8 +1113,6 @@ function ActionModal({ paddingLeft: 'max(1rem, env(safe-area-inset-left))', paddingRight: 'max(1rem, env(safe-area-inset-right))', }} - role="dialog" - aria-modal="true" > {/* Backdrop — no close on click during loading */}
- {/* Modal */} -
+ {/* Modal — role/aria-modal live on the focus-trapped element so AT + announces and Tab cycles correctly. */} +
{/* Header */}
-

{t(actionLabelKeys[modal.action])}

+

+ {t(actionLabelKeys[modal.action])} +

{!modal.loading && (