1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115import { useEffect, useRef, useCallback } from 'react';
interface ModalProps {
onClose: () => void;
'aria-label': string;
children: React.ReactNode;
className?: string;
}
/**
* Accessible modal with focus trapping, Escape to close, and click-outside to close.
* Focus is trapped within the modal and restored to the previously focused element on close.
*/
export function Modal({ onClose, 'aria-label': ariaLabel, children, className }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Store previously focused element and focus the modal on mount
useEffect(() => {
previousFocusRef.current = document.activeElement as HTMLElement;
// Focus the first focusable element inside the modal
requestAnimationFrame(() => {
const focusable = getFocusableElements();
if (focusable.length > 0) {
focusable[0].focus();
} else {
modalRef.current?.focus();
}
});
return () => {
// Restore focus on unmount
previousFocusRef.current?.focus();
};
}, []);
const getFocusableElements = useCallback((): HTMLElement[] => {
if (!modalRef.current) return [];
return Array.from(
modalRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
}, []);
// Handle keyboard: Escape to close, Tab to trap focus
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onClose();
return;
}
if (e.key === 'Tab') {
const focusable = getFocusableElements();
if (focusable.length === 0) {
e.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [onClose, getFocusableElements]);
// Prevent body scroll while modal is open
useEffect(() => {
const original = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = original;
};
}, []);
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={(e) => {
if (e.target === overlayRef.current) onClose();
}}
role="presentation"
>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
tabIndex={-1}
className={className}
>
{children}
</div>
</div>
);
}