/** * Modal Component * Reusable modal overlay with animations */ 'use client'; import { useEffect, useCallback, useId, useRef } from 'react'; import { createPortal } from 'react-dom'; import styles from './Modal.module.css'; interface ModalProps { isOpen: boolean; onClose: () => void; children: React.ReactNode; title?: string; size?: 'small' | 'medium' | 'large'; /** Pinned below the scrolling content, so its actions stay in reach. */ footer?: React.ReactNode; } export function Modal({ isOpen, onClose, children, title, size = 'medium', footer }: ModalProps) { const overlayRef = useRef(null); const dialogRef = useRef(null); const titleId = useId(); const handleEscape = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } }, [onClose]); useEffect(() => { if (!isOpen) return; // Add event listener document.addEventListener('keydown', handleEscape); // Prevent body scroll document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', handleEscape); document.body.style.overflow = 'unset'; }; }, [isOpen, handleEscape]); // Focus moves into the dialog when it opens and back to whatever opened it // when it closes. A child that has already taken focus (an autoFocus input) // keeps it: children's effects run before this one. useEffect(() => { if (!isOpen) return; const opener = document.activeElement as HTMLElement | null; const dialog = dialogRef.current; if (dialog && !dialog.contains(document.activeElement)) dialog.focus(); return () => opener?.focus?.(); }, [isOpen]); // Pin the overlay to the VISUAL viewport, not the layout viewport. On mobile // the on-screen keyboard shrinks the visual viewport but not the layout one, // so a `position: fixed; inset: 0` overlay keeps full height — leaving the // bottom-anchored sheet (and the dim backdrop's lower half) hidden behind // the keyboard. Tracking visualViewport.height/offsetTop keeps the whole // overlay — backdrop and sheet — inside the visible area, above the keyboard. useEffect(() => { if (!isOpen) return; const vv = typeof window !== 'undefined' ? window.visualViewport : null; const el = overlayRef.current; if (!vv || !el) return; const sync = () => { el.style.top = `${vv.offsetTop}px`; el.style.height = `${vv.height}px`; el.style.bottom = 'auto'; }; sync(); vv.addEventListener('resize', sync); vv.addEventListener('scroll', sync); return () => { vv.removeEventListener('resize', sync); vv.removeEventListener('scroll', sync); }; }, [isOpen]); if (!isOpen || typeof window === 'undefined') return null; const handleOverlayClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } }; return createPortal(
{title &&

{title}

}
{children}
{footer &&
{footer}
}
, document.body ); }