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