feat(a11y): add ARIA attributes and focus trap to Modal component
- Add role="dialog" and aria-modal="true" to modal container - Add aria-labelledby linked to title element - Integrate useDialogFocus hook for focus management - Wrap content with FocusLock from react-focus-lock - Add triggerRef prop for focus restoration - Add titleId prop for custom aria-labelledby - Preserve existing Escape key and backdrop click behavior
This commit is contained in:
@@ -4,8 +4,10 @@
|
||||
* A reusable modal dialog component.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useCallback } from 'react'
|
||||
import React, { useEffect, useCallback, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import FocusLock from 'react-focus-lock'
|
||||
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
@@ -14,6 +16,10 @@ interface ModalProps {
|
||||
children: React.ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
||||
showCloseButton?: boolean
|
||||
/** Ref to the element that triggered opening the modal (for focus restoration) */
|
||||
triggerRef?: React.RefObject<HTMLElement | null>
|
||||
/** ID of the title element (for aria-labelledby) */
|
||||
titleId?: string
|
||||
}
|
||||
|
||||
const sizeStyles: Record<string, string> = {
|
||||
@@ -31,9 +37,24 @@ export function Modal({
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
showCloseButton = true
|
||||
showCloseButton = true,
|
||||
triggerRef,
|
||||
titleId
|
||||
}: ModalProps) {
|
||||
// Handle escape key
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Generate unique title ID if not provided
|
||||
const generatedTitleId = titleId || `modal-title-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
// Setup focus management
|
||||
const { focusLockProps } = useDialogFocus({
|
||||
isOpen,
|
||||
dialogRef,
|
||||
onClose,
|
||||
triggerRef: triggerRef || undefined
|
||||
})
|
||||
|
||||
// Handle escape key (maintained for backward compatibility, useDialogFocus also handles this)
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
@@ -46,48 +67,59 @@ export function Modal({
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = 'unset'
|
||||
}
|
||||
}, [isOpen, handleKeyDown])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onClick={onClose} />
|
||||
|
||||
{/* Modal container */}
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<FocusLock {...focusLockProps}>
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
{title && <h3 className="text-lg font-semibold text-gray-900">{title}</h3>}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4">{children}</div>
|
||||
{/* Modal container */}
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={generatedTitleId}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
{title && (
|
||||
<h3 id={generatedTitleId} className="text-lg font-semibold text-gray-900">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusLock>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user