nico-martin's picture
nico-martin HF Staff
init
9b72f0d
import cn from "@utils/classnames";
import { X } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
className?: string;
size?: "sm" | "md" | "lg" | "xl" | "full";
showCloseButton?: boolean;
}
const sizeStyles = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-xl",
xl: "max-w-3xl",
full: "max-w-full md:mx-4",
};
// Get all focusable elements within a container
const getFocusableElements = (container: HTMLElement): HTMLElement[] => {
const focusableSelectors = [
"a[href]",
"button:not([disabled])",
"textarea:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(", ");
return Array.from(container.querySelectorAll(focusableSelectors));
};
export default function Modal({
isOpen,
onClose,
title,
children,
className = "",
size = "md",
showCloseButton = true,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
if (isOpen) {
setShouldRender(true);
// Start animation after render
const timer = setTimeout(() => setIsAnimating(true), 10);
return () => clearTimeout(timer);
} else {
setIsAnimating(false);
// Remove from DOM after animation completes
const timer = setTimeout(() => setShouldRender(false), 200);
return () => clearTimeout(timer);
}
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "unset";
};
}, [isOpen, onClose]);
useEffect(() => {
if (!isAnimating || !modalRef.current) return;
const modal = modalRef.current;
const focusableElements = getFocusableElements(modal);
if (focusableElements.length === 0) {
modal.focus();
return;
}
const firstFocusable = focusableElements[0];
const lastFocusable = focusableElements[focusableElements.length - 1];
firstFocusable.focus();
const handleTabKey = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
// Shift + Tab (backwards)
if (e.shiftKey) {
if (document.activeElement === firstFocusable) {
e.preventDefault();
lastFocusable.focus();
}
}
// Tab (forwards)
else {
if (document.activeElement === lastFocusable) {
e.preventDefault();
firstFocusable.focus();
}
}
};
modal.addEventListener("keydown", handleTabKey);
return () => {
modal.removeEventListener("keydown", handleTabKey);
};
}, [isAnimating]);
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
if (!shouldRender) return null;
const modalContent = (
<div
className={cn(
"fixed inset-0 z-50 flex items-center justify-center p-4 transition-all duration-200",
isAnimating ? "opacity-100" : "opacity-0"
)}
onClick={handleBackdropClick}
>
{/* Backdrop */}
<div
className={cn(
"absolute inset-0 bg-black/50 transition-opacity duration-200",
isAnimating ? "opacity-100" : "opacity-0"
)}
/>
{/* Modal */}
<div
ref={modalRef}
tabIndex={-1}
className={cn(
"relative w-full rounded-lg border border-neutral-200 bg-white shadow-xl transition-all duration-200 dark:border-gray-700",
"dark:bg-gray-900",
"flex max-h-[90vh] flex-col",
sizeStyles[size],
isAnimating ? "scale-100 opacity-100" : "scale-95 opacity-0",
className
)}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 p-4 dark:border-gray-700">
{title && (
<h2
id="modal-title"
className="text-lg font-semibold text-neutral-900 dark:text-gray-100"
>
{title}
</h2>
)}
{showCloseButton && (
<button
onClick={onClose}
className="ml-auto cursor-pointer rounded-full p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
aria-label="Close modal"
>
<X size={20} />
</button>
)}
</div>
)}
{/* Content */}
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto",
title || showCloseButton ? "p-4" : "p-0"
)}
>
{children}
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}