2026-01-13 23:45:28 +01:00

134 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef } from 'react'
import type { ReactNode } from 'react'
interface ModalProps {
children: ReactNode
isOpen: boolean
onClose: () => void
title?: string
size?: 'small' | 'medium' | 'large' | 'full'
showCloseButton?: boolean
'aria-label'?: string
}
function getSizeClasses(size: ModalProps['size']): string {
switch (size) {
case 'small':
return 'max-w-md'
case 'medium':
return 'max-w-lg'
case 'large':
return 'max-w-xl'
case 'full':
return 'max-w-full mx-4'
default:
return 'max-w-md'
}
}
function CloseButton({ onClose }: { onClose: () => void }): React.ReactElement {
return (
<button
onClick={onClose}
className="text-cyber-accent hover:text-neon-cyan text-2xl transition-colors focus:outline-none focus:ring-2 focus:ring-neon-cyan rounded"
aria-label="Close modal"
>
×
</button>
)
}
function ModalHeader({
title,
showCloseButton,
onClose,
}: {
title?: string
showCloseButton: boolean
onClose: () => void
}): React.ReactElement | null {
if (!title && !showCloseButton) {
return null
}
return (
<div className="flex justify-between items-center mb-4">
{title && <h2 className="text-xl font-bold text-neon-cyan">{title}</h2>}
{showCloseButton && <CloseButton onClose={onClose} />}
</div>
)
}
export function Modal({
children,
isOpen,
onClose,
title,
size = 'medium',
showCloseButton = true,
'aria-label': ariaLabel,
}: ModalProps): React.ReactElement | null {
const modalRef = useRef<HTMLDivElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement
const firstFocusable = modalRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as HTMLElement | null
if (firstFocusable) {
firstFocusable.focus()
}
} else {
previousFocusRef.current?.focus()
}
}, [isOpen])
useEffect(() => {
const handleEscape = (e: KeyboardEvent): void => {
if (e.key === 'Escape' && isOpen) {
onClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = ''
}
}, [isOpen, onClose])
if (!isOpen) {
return null
}
const sizeClasses = getSizeClasses(size)
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose()
}
}}
role="dialog"
aria-modal="true"
aria-label={ariaLabel ?? title}
>
<div
ref={modalRef}
className={`bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 w-full mx-4 max-h-[90vh] overflow-y-auto shadow-glow-cyan ${sizeClasses}`}
onClick={(e) => e.stopPropagation()}
>
<ModalHeader title={title} showCloseButton={showCloseButton} onClose={onClose} />
{children}
</div>
</div>
)
}