119 lines
3.8 KiB
TypeScript
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>
|
|
);
|
|
}
|