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.
|
* A reusable modal dialog component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useCallback } from 'react'
|
import React, { useEffect, useCallback, useRef } from 'react'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
|
import FocusLock from 'react-focus-lock'
|
||||||
|
import { useDialogFocus } from '../../hooks/useDialogFocus'
|
||||||
|
|
||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -14,6 +16,10 @@ interface ModalProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
||||||
showCloseButton?: boolean
|
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> = {
|
const sizeStyles: Record<string, string> = {
|
||||||
@@ -31,9 +37,24 @@ export function Modal({
|
|||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
showCloseButton = true
|
showCloseButton = true,
|
||||||
|
triggerRef,
|
||||||
|
titleId
|
||||||
}: ModalProps) {
|
}: 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(
|
const handleKeyDown = useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
@@ -46,48 +67,59 @@ export function Modal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown)
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
document.body.style.overflow = 'unset'
|
|
||||||
}
|
}
|
||||||
}, [isOpen, handleKeyDown])
|
}, [isOpen, handleKeyDown])
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<FocusLock {...focusLockProps}>
|
||||||
{/* Backdrop */}
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onClick={onClose} />
|
{/* Backdrop */}
|
||||||
|
|
||||||
{/* Modal container */}
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4">
|
|
||||||
<div
|
<div
|
||||||
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`}
|
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={onClose}
|
||||||
>
|
/>
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Modal container */}
|
||||||
<div className="px-6 py-4">{children}</div>
|
<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>
|
</div>
|
||||||
</div>
|
</FocusLock>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user