2026-02-02 20:34:35 +00:00
|
|
|
/**
|
|
|
|
|
* Modal Component
|
|
|
|
|
* Reusable modal overlay with animations
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
'use client';
|
|
|
|
|
|
2026-07-18 08:00:31 +01:00
|
|
|
import { useEffect, useCallback, useRef } from 'react';
|
2026-02-02 20:34:35 +00:00
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function Modal({ isOpen, onClose, children, title, size = 'medium' }: ModalProps) {
|
2026-07-18 08:00:31 +01:00
|
|
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
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]);
|
|
|
|
|
|
2026-07-18 08:00:31 +01:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
if (!isOpen || typeof window === 'undefined') return null;
|
|
|
|
|
|
|
|
|
|
const handleOverlayClick = (e: React.MouseEvent) => {
|
|
|
|
|
if (e.target === e.currentTarget) {
|
|
|
|
|
onClose();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return createPortal(
|
2026-07-18 08:00:31 +01:00
|
|
|
<div ref={overlayRef} className={styles.overlay} onClick={handleOverlayClick}>
|
2026-02-02 20:34:35 +00:00
|
|
|
<div className={`${styles.modal} ${styles[size]}`}>
|
|
|
|
|
<div className={styles.header}>
|
|
|
|
|
{title && <h2 className={styles.title}>{title}</h2>}
|
|
|
|
|
<button
|
|
|
|
|
className={styles.closeButton}
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
aria-label="Close modal"
|
|
|
|
|
>
|
|
|
|
|
<svg
|
|
|
|
|
width="24"
|
|
|
|
|
height="24"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
>
|
|
|
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
|
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className={styles.content}>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>,
|
|
|
|
|
document.body
|
|
|
|
|
);
|
|
|
|
|
}
|