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:
test
2026-03-08 17:04:04 +08:00
parent 62e1647eaf
commit 545048045d

View File

@@ -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>
) )
} }