188 lines
4.3 KiB
TypeScript
188 lines
4.3 KiB
TypeScript
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>
|
||
)
|
||
}
|