2026-01-14 00:34:36 +01:00

188 lines
4.3 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 | undefined
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>
)
}
function useModalFocus(modalRef: React.RefObject<HTMLDivElement | null>, isOpen: boolean): void {
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (isOpen) {
const { activeElement } = document
if (activeElement instanceof HTMLElement) {
previousFocusRef.current = activeElement
}
const firstFocusable = modalRef.current?.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
if (firstFocusable instanceof HTMLElement) {
firstFocusable.focus()
}
} else {
previousFocusRef.current?.focus()
}
}, [isOpen, modalRef])
}
function useModalKeyboard(isOpen: boolean, onClose: () => void): void {
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])
}
function ModalOverlay({
onClose,
ariaLabel,
children,
}: {
onClose: () => void
ariaLabel: string | undefined
children: ReactNode
}): React.ReactElement {
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}
>
{children}
</div>
)
}
function ModalContent({
modalRef,
sizeClasses,
title,
showCloseButton,
onClose,
children,
}: {
modalRef: React.RefObject<HTMLDivElement | null>
sizeClasses: string
title?: string | undefined
showCloseButton: boolean
onClose: () => void
children: ReactNode
}): React.ReactElement {
return (
<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>
)
}
export function Modal({
children,
isOpen,
onClose,
title,
size = 'medium',
showCloseButton = true,
'aria-label': ariaLabel,
}: ModalProps): React.ReactElement | null {
const modalRef = useRef<HTMLDivElement>(null)
useModalFocus(modalRef, isOpen)
useModalKeyboard(isOpen, onClose)
if (!isOpen) {
return null
}
const sizeClasses = getSizeClasses(size)
return (
<ModalOverlay onClose={onClose} ariaLabel={ariaLabel ?? title}>
<ModalContent
modalRef={modalRef}
sizeClasses={sizeClasses}
title={title}
showCloseButton={showCloseButton}
onClose={onClose}
>
{children}
</ModalContent>
</ModalOverlay>
)
}