2026-02-02 20:34:35 +00:00
|
|
|
/**
|
|
|
|
|
* Modal Component
|
|
|
|
|
* Reusable modal overlay with animations
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
'use client';
|
|
|
|
|
|
2026-10-02 09:31:53 +01:00
|
|
|
import { useEffect, useCallback, useId, 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';
|
2026-10-02 09:31:53 +01:00
|
|
|
/** Pinned below the scrolling content, so its actions stay in reach. */
|
|
|
|
|
footer?: React.ReactNode;
|
2026-02-02 20:34:35 +00:00
|
|
|
}
|
|
|
|
|
|
2026-10-02 09:31:53 +01:00
|
|
|
export function Modal({ isOpen, onClose, children, title, size = 'medium', footer }: ModalProps) {
|
2026-07-18 08:00:31 +01:00
|
|
|
const overlayRef = useRef<HTMLDivElement>(null);
|
2026-10-02 09:31:53 +01:00
|
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const titleId = useId();
|
2026-07-18 08:00:31 +01:00
|
|
|
|
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-10-02 09:31:53 +01:00
|
|
|
// 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]);
|
|
|
|
|
|
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-10-02 09:31:53 +01:00
|
|
|
<div
|
|
|
|
|
ref={dialogRef}
|
|
|
|
|
className={`${styles.modal} ${styles[size]}`}
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
aria-labelledby={title ? titleId : undefined}
|
|
|
|
|
tabIndex={-1}
|
|
|
|
|
>
|
2026-02-02 20:34:35 +00:00
|
|
|
<div className={styles.header}>
|
2026-10-02 09:31:53 +01:00
|
|
|
{title && <h2 id={titleId} className={styles.title}>{title}</h2>}
|
2026-02-02 20:34:35 +00:00
|
|
|
<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>
|
2026-10-02 09:31:53 +01:00
|
|
|
{footer && <div className={styles.footer}>{footer}</div>}
|
2026-02-02 20:34:35 +00:00
|
|
|
</div>
|
|
|
|
|
</div>,
|
|
|
|
|
document.body
|
|
|
|
|
);
|
|
|
|
|
}
|