| "use client"; |
|
|
| import { useEffect, useRef, useId } from "react"; |
| import { cn } from "@/shared/utils/cn"; |
| import Button from "./Button"; |
|
|
| interface ModalProps { |
| isOpen: boolean; |
| onClose: () => void; |
| title?: React.ReactNode; |
| children?: React.ReactNode; |
| footer?: React.ReactNode; |
| size?: "sm" | "md" | "lg" | "xl" | "full"; |
| closeOnOverlay?: boolean; |
| showCloseButton?: boolean; |
| className?: string; |
| maxWidth?: string; |
| } |
|
|
| export default function Modal({ |
| isOpen, |
| onClose, |
| title, |
| children, |
| footer, |
| size = "md", |
| closeOnOverlay = true, |
| showCloseButton = true, |
| className, |
| }: ModalProps) { |
| const titleId = useId(); |
| const dialogRef = useRef(null); |
|
|
| const sizes = { |
| sm: "max-w-sm", |
| md: "max-w-md", |
| lg: "max-w-lg", |
| xl: "max-w-xl", |
| full: "max-w-4xl", |
| }; |
|
|
| |
| useEffect(() => { |
| if (isOpen) { |
| document.body.style.overflow = "hidden"; |
| } else { |
| document.body.style.overflow = ""; |
| } |
| return () => { |
| document.body.style.overflow = ""; |
| }; |
| }, [isOpen]); |
|
|
| |
| useEffect(() => { |
| const handleEscape = (e) => { |
| if (e.key === "Escape" && isOpen) { |
| onClose(); |
| } |
| }; |
| document.addEventListener("keydown", handleEscape); |
| return () => document.removeEventListener("keydown", handleEscape); |
| }, [isOpen, onClose]); |
|
|
| |
| useEffect(() => { |
| if (!isOpen || !dialogRef.current) return; |
|
|
| const dialog = dialogRef.current; |
| const focusableSelector = |
| 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; |
|
|
| |
| const firstFocusable = dialog.querySelector(focusableSelector); |
| if (firstFocusable) { |
| setTimeout(() => firstFocusable.focus(), 50); |
| } |
|
|
| const handleTab = (e) => { |
| if (e.key !== "Tab") return; |
|
|
| const focusable = [...dialog.querySelectorAll(focusableSelector)]; |
| if (focusable.length === 0) return; |
|
|
| const first = focusable[0]; |
| const last = focusable[focusable.length - 1]; |
|
|
| if (e.shiftKey && document.activeElement === first) { |
| e.preventDefault(); |
| last.focus(); |
| } else if (!e.shiftKey && document.activeElement === last) { |
| e.preventDefault(); |
| first.focus(); |
| } |
| }; |
|
|
| dialog.addEventListener("keydown", handleTab); |
| return () => dialog.removeEventListener("keydown", handleTab); |
| }, [isOpen]); |
|
|
| if (!isOpen) return null; |
|
|
| return ( |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4"> |
| {/* Overlay */} |
| <div |
| className="absolute inset-0 bg-black/30 backdrop-blur-sm" |
| onClick={closeOnOverlay ? onClose : undefined} |
| aria-hidden="true" |
| /> |
| |
| {/* Modal content */} |
| <div |
| ref={dialogRef} |
| role="dialog" |
| aria-modal="true" |
| aria-labelledby={title ? titleId : undefined} |
| className={cn( |
| "relative w-full bg-surface", |
| "border border-black/10 dark:border-white/10", |
| "rounded-xl shadow-2xl", |
| "animate-in fade-in zoom-in-95 duration-200", |
| sizes[size], |
| className |
| )} |
| > |
| {/* Header */} |
| {(title || showCloseButton) && ( |
| <div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5"> |
| <div className="flex items-center"> |
| <div className="flex items-center gap-2 mr-4" aria-hidden="true"> |
| <div className="w-3 h-3 rounded-full bg-[#FF5F56]" /> |
| <div className="w-3 h-3 rounded-full bg-[#FFBD2E]" /> |
| <div className="w-3 h-3 rounded-full bg-[#27C93F]" /> |
| </div> |
| {title && ( |
| <h2 id={titleId} className="text-lg font-semibold text-text-main"> |
| {title} |
| </h2> |
| )} |
| </div> |
| {showCloseButton && ( |
| <button |
| onClick={onClose} |
| aria-label="Close" |
| className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors" |
| > |
| <span className="material-symbols-outlined text-[20px]" aria-hidden="true"> |
| close |
| </span> |
| </button> |
| )} |
| </div> |
| )} |
| |
| {/* Body */} |
| <div className="p-6 max-h-[calc(80vh-140px)] overflow-y-auto">{children}</div> |
| |
| {/* Footer */} |
| {footer && ( |
| <div className="flex items-center justify-end gap-3 p-6 border-t border-black/5 dark:border-white/5"> |
| {footer} |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| export function ConfirmModal({ |
| isOpen, |
| onClose, |
| onConfirm, |
| title = "Confirm", |
| message, |
| confirmText = "Confirm", |
| cancelText = "Cancel", |
| variant = "danger", |
| loading = false, |
| }) { |
| return ( |
| <Modal |
| isOpen={isOpen} |
| onClose={onClose} |
| title={title} |
| size="sm" |
| footer={ |
| <> |
| <Button variant="ghost" onClick={onClose} disabled={loading}> |
| {cancelText} |
| </Button> |
| <Button variant={variant as any} onClick={onConfirm} loading={loading}> |
| {confirmText} |
| </Button> |
| </> |
| } |
| > |
| <p className="text-text-muted">{message}</p> |
| </Modal> |
| ); |
| } |
|
|