File size: 2,132 Bytes
1f21206 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | import { useEffect, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
type ModalProps = {
open: boolean
onClose: () => void
title?: string
children: ReactNode
width?: number
footer?: ReactNode
}
export function Modal({ open, onClose, title, children, width = 560, footer }: ModalProps) {
useEffect(() => {
if (!open) return
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleEsc)
return () => document.removeEventListener('keydown', handleEsc)
}, [open, onClose])
if (!open) return null
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-[var(--color-overlay-scrim)] transition-opacity duration-200"
onClick={onClose}
/>
{/* Modal content */}
<div
className="glass-panel relative rounded-[var(--radius-xl)] max-h-[85vh] flex flex-col"
style={{ width, maxWidth: 'calc(100vw - 48px)' }}
role="dialog"
aria-modal="true"
aria-label={title}
>
{title && (
<div className="flex items-start justify-between gap-4 px-6 pt-6 pb-0">
<h2 className="text-xl font-bold text-[var(--color-text-primary)]">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close dialog"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
)}
<div className="px-6 py-4 overflow-y-auto flex-1">
{children}
</div>
{footer && (
<div className="px-6 pb-6 pt-0 flex justify-end gap-2">
{footer}
</div>
)}
</div>
</div>,
document.body,
)
}
|