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.
This commit is contained in:
c0mrade
2026-05-26 13:44:46 +03:00
parent 7817243253
commit bc34e65aac

View File

@@ -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<HTMLDivElement>(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 */}
<div
@@ -1127,11 +1120,21 @@ function ActionModal({
onClick={handleBackdropClick}
/>
{/* Modal */}
<div className="relative max-h-[calc(100dvh-2rem)] w-full max-w-md overflow-y-auto rounded-2xl border border-dark-700 bg-dark-900 p-6 shadow-2xl">
{/* Modal — role/aria-modal live on the focus-trapped element so AT
announces and Tab cycles correctly. */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="bulk-action-modal-title"
tabIndex={-1}
className="relative max-h-[calc(100dvh-2rem)] w-full max-w-md overflow-y-auto rounded-2xl border border-dark-700 bg-dark-900 p-6 shadow-2xl"
>
{/* Header */}
<div className="mb-5 flex items-center justify-between">
<h3 className="text-lg font-bold text-dark-100">{t(actionLabelKeys[modal.action])}</h3>
<h3 id="bulk-action-modal-title" className="text-lg font-bold text-dark-100">
{t(actionLabelKeys[modal.action])}
</h3>
{!modal.loading && (
<button
onClick={onClose}