2025-10-08 18:10:07 -06:00

119 lines
3.8 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { motion, AnimatePresence } from "motion/react";
interface ModalProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
title?: string;
className?: string;
}
export function Modal({ open, onClose, children, title, className = "" }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Lock body scroll when modal is open
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
// Store current focus
previousFocusRef.current = document.activeElement as HTMLElement;
} else {
document.body.style.overflow = "";
// Restore focus when modal closes
if (previousFocusRef.current) {
previousFocusRef.current.focus();
}
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
// Focus trap and ESC key handling
useEffect(() => {
if (!open || !modalRef.current) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key === "Tab") {
const modal = modalRef.current;
if (!modal) return;
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
if (event.shiftKey) {
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement?.focus();
}
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
ref={modalRef}
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
role="dialog"
aria-modal="true"
className={`relative w-full max-w-4xl max-h-[90vh] rounded-lg shadow-2xl glass-strong glass-refract overflow-hidden ${className}`}
onClick={(e) => e.stopPropagation()}
>
{title && (
<div className="flex items-center justify-between p-4 border-b border-white/10">
<h2 className="text-lg font-semibold text-neutral-100">{title}</h2>
<button
onClick={onClose}
className="p-1 rounded-full hover:bg-white/5 transition-colors"
aria-label="Close modal"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)}
<div className="overflow-auto max-h-[calc(90vh-4rem)]">
{children}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}