"use client"; import React, { useEffect, forwardRef, useRef } from "react"; import { BaseComponentProps } from "../../types"; export interface ModalProps extends BaseComponentProps { open?: boolean; onClose?: () => void; onOpenChange?: (open: boolean) => void; size?: "sm" | "md" | "lg" | "xl" | "full"; showCloseButton?: boolean; closeOnOverlayClick?: boolean; closeOnEscape?: boolean; role?: "dialog" | "alertdialog"; ariaLabelledBy?: string; ariaDescribedBy?: string; animation?: boolean; } export const Modal = forwardRef( ( { children, className = "", open = false, onClose, onOpenChange, size = "md", showCloseButton = true, closeOnOverlayClick = true, closeOnEscape = true, role = "dialog", ariaLabelledBy, ariaDescribedBy, animation = true, ...props }, ref ) => { const previousFocusRef = useRef(null); const modalRef = useRef(null); const handleClose = () => { onClose?.(); onOpenChange?.(false); }; useEffect(() => { if (open) { // Store the previously focused element previousFocusRef.current = document.activeElement as HTMLElement; // Focus the modal content setTimeout(() => { const modalElement = modalRef.current; if (modalElement) { const focusableElement = modalElement.querySelector( 'button:not([aria-label="Close"]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) as HTMLElement; if (focusableElement) { focusableElement.focus(); } else { modalElement.focus(); } } }, 0); } return () => { // Restore focus when modal closes if (!open && previousFocusRef.current) { previousFocusRef.current.focus(); } }; }, [open]); useEffect(() => { const handleEscape = (event: KeyboardEvent) => { if (event.key === "Escape" && closeOnEscape && handleClose) { handleClose(); } }; const handleTabTrap = (event: KeyboardEvent) => { if (event.key === "Tab" && modalRef.current) { const focusableElements = modalRef.current.querySelectorAll( 'button:not([aria-label="Close"]), [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) { lastElement?.focus(); event.preventDefault(); } } else { if (document.activeElement === lastElement) { firstElement?.focus(); event.preventDefault(); } } } }; if (open) { document.addEventListener("keydown", handleEscape); document.addEventListener("keydown", handleTabTrap); document.body.style.overflow = "hidden"; // Set aria-hidden on all other elements const rootElements = document.querySelectorAll( "body > *:not([aria-live])" ); rootElements.forEach((element) => { if (element !== modalRef.current?.closest("[data-modal-root]")) { element.setAttribute("aria-hidden", "true"); } }); } return () => { document.removeEventListener("keydown", handleEscape); document.removeEventListener("keydown", handleTabTrap); document.body.style.overflow = "unset"; // Remove aria-hidden from all elements const hiddenElements = document.querySelectorAll( '[aria-hidden="true"]' ); hiddenElements.forEach((element) => { element.removeAttribute("aria-hidden"); }); }; }, [open, closeOnEscape, handleClose]); if (!open) return null; const sizeClasses = { sm: "max-w-md", md: "max-w-lg", lg: "max-w-2xl", xl: "max-w-4xl", full: "max-w-[95vw] max-h-[95vh]", }; const handleBackdropClick = (event: React.MouseEvent) => { if (event.target === event.currentTarget && closeOnOverlayClick) { handleClose(); } }; return (
{ if (typeof ref === "function") { ref(node); } else if (ref) { ref.current = node; } // modalRef is handled by the callback ( modalRef as React.MutableRefObject ).current = node; }} className={`relative w-full ${ sizeClasses[size] } bg-background rounded-lg shadow-lg border ${ animation ? "animate-in zoom-in-95 duration-300" : "" } ${className}`} role={role} aria-labelledby={ariaLabelledBy} aria-describedby={ariaDescribedBy} tabIndex={-1} {...props} > {showCloseButton && ( )} {children}
); } ); Modal.displayName = "Modal"; export const ModalHeader = forwardRef( ({ className = "", children, ...props }, ref) => { return (
{children}
); } ); ModalHeader.displayName = "ModalHeader"; export interface ModalTitleProps extends BaseComponentProps { as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; } export const ModalTitle = forwardRef( ({ className = "", as: Component = "h2", children, ...props }, ref) => { return ( {children} ); } ); ModalTitle.displayName = "ModalTitle"; export const ModalDescription = forwardRef< HTMLParagraphElement, BaseComponentProps >(({ className = "", children, ...props }, ref) => { return (

{children}

); }); ModalDescription.displayName = "ModalDescription"; export const ModalContent = forwardRef( ({ className = "", children, ...props }, ref) => { return (
{children}
); } ); ModalContent.displayName = "ModalContent"; export interface ModalFooterProps extends BaseComponentProps { justify?: "start" | "end" | "center" | "between"; } export const ModalFooter = forwardRef( ({ className = "", justify = "end", children, ...props }, ref) => { const justifyClasses = { start: "justify-start", end: "justify-end", center: "justify-center", between: "justify-between", }; return (
{children}
); } ); ModalFooter.displayName = "ModalFooter";