import { useEffect, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
export type ModalSize = 'sm' | 'md' | 'lg'
interface ModalProps {
open: boolean
onClose: () => void
title: string
size?: ModalSize
children: ReactNode
footer?: ReactNode
/** Disable backdrop-click-to-close (e.g. during in-flight submit). */
dismissable?: boolean
}
const FOCUSABLE = [
'a[href]',
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')
export default function Modal({
open,
onClose,
title,
size = 'md',
children,
footer,
dismissable = true,
}: ModalProps) {
const cardRef = useRef<HTMLDivElement | null>(null)
const lastFocusedRef = useRef<HTMLElement | null>(null)
// Preserve and restore the element that had focus before the modal opened.
useEffect(() => {
if (!open) return
lastFocusedRef.current = (document.activeElement as HTMLElement | null) ?? null
const card = cardRef.current
if (card) {
const first = card.querySelector<HTMLElement>(FOCUSABLE)
;(first ?? card).focus()
}
return () => {
lastFocusedRef.current?.focus?.()
}
}, [open])
// Lock body scroll while any modal is open.
useEffect(() => {
if (!open) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = prev
}
}, [open])
// ESC closes + Tab focus trap inside the card.
useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && dismissable) {
e.stopPropagation()
onClose()
return
}
if (e.key !== 'Tab') return
const card = cardRef.current
if (!card) return
const focusables = Array.from(card.querySelectorAll<HTMLElement>(FOCUSABLE))
.filter((el) => !el.hasAttribute('data-focus-skip'))
if (focusables.length === 0) {
e.preventDefault()
return
}
const first = focusables[0]
const last = focusables[focusables.length - 1]
const active = document.activeElement as HTMLElement | null
if (e.shiftKey && active === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [open, dismissable, onClose])
if (!open) return null
return createPortal(
<div
className="modal-backdrop-v2"
onClick={() => {
if (dismissable) onClose()
}}
>
<div
ref={cardRef}
className={`modal-v2 modal-${size}`}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<div className="modal-title">{title}</div>
<button
type="button"
className="modal-close"
aria-label="Close"
onClick={onClose}
disabled={!dismissable}
>
×
</button>
</div>
<div className="modal-body">{children}</div>
{footer != null && <div className="modal-footer">{footer}</div>}
</div>
</div>,
document.body,
)
}