"use client"; import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; import { useModalPortal } from "@/lib/modalPortalRef"; import { useTabId } from "@/contexts/TabIdContext"; import { useTabStore } from "@/stores/tabStore"; import { useDialogAutoValidation } from "@/lib/hooks/useDialogAutoValidation"; // Dialog: 탭 시스템 내에서 자동으로 modal={false} + 비활성 탭이면 open={false} 처리 const Dialog: React.FC> = ({ modal, open, onOpenChange, ...props }) => { const autoContainer = useModalPortal(); const scoped = !!autoContainer; const tabId = useTabId(); const activeTabId = useTabStore((s) => s[s.mode].activeTabId); const isTabActive = !tabId || tabId === activeTabId; // ref로 최신 isTabActive를 동기적으로 추적 (useEffect보다 빠르게 업데이트) const isTabActiveRef = React.useRef(isTabActive); isTabActiveRef.current = isTabActive; const effectiveModal = modal !== undefined ? modal : !scoped ? undefined : false; const effectiveOpen = open != null ? open && isTabActive : undefined; // 비활성 탭에서 발생하는 onOpenChange(false) 차단 // (탭 전환 시 content unmount → focus 이동 → Radix가 onOpenChange(false)를 호출하는 것을 방지) const guardedOnOpenChange = React.useCallback( (newOpen: boolean) => { if (scoped && !newOpen && !isTabActiveRef.current) { return; } onOpenChange?.(newOpen); }, [scoped, onOpenChange, tabId], ); return ; }; Dialog.displayName = "Dialog"; const DialogTrigger = DialogPrimitive.Trigger; const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; const DialogOverlay = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; interface ScopedDialogContentProps extends React.ComponentPropsWithoutRef { /** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */ container?: HTMLElement | null; /** 탭 비활성 시 포탈 내용 숨김 */ hidden?: boolean; } const DialogContent = React.forwardRef< React.ElementRef, ScopedDialogContentProps >(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, onFocusOutside, style, ...props }, ref) => { const autoContainer = useModalPortal(); const container = explicitContainer !== undefined ? explicitContainer : autoContainer; const scoped = !!container; // state 기반 ref: DialogPrimitive.Content 마운트/언마운트 시 useEffect 재실행 보장 const [contentNode, setContentNode] = React.useState(null); const mergedRef = React.useCallback( (node: HTMLDivElement | null) => { setContentNode(node); if (typeof ref === "function") ref(node); else if (ref) (ref as React.MutableRefObject).current = node; }, [ref], ); useDialogAutoValidation(contentNode); const handleInteractOutside = React.useCallback( (e: any) => { if (scoped && container) { const target = (e.detail?.originalEvent?.target ?? e.target) as HTMLElement | null; if (target && !container.contains(target)) { e.preventDefault(); return; } } onInteractOutside?.(e); }, [scoped, container, onInteractOutside], ); // scoped 모드: content unmount 시 포커스 이동으로 인한 onOpenChange(false) 방지 const handleFocusOutside = React.useCallback( (e: any) => { if (scoped) { e.preventDefault(); return; } onFocusOutside?.(e); }, [scoped, onFocusOutside], ); // scoped 모드: 뷰포트 기반 maxHeight/maxWidth 제거 → className의 max-h-full이 컨테이너 기준으로 적용됨 const adjustedStyle = scoped && style ? { ...style, maxHeight: undefined, maxWidth: undefined } : style; return (
{scoped ? (
) : ( )} {children} Close
); }); DialogContent.displayName = DialogPrimitive.Content.displayName; const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
); DialogHeader.displayName = "DialogHeader"; const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
); DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogTitle.displayName = DialogPrimitive.Title.displayName; const DialogDescription = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )); DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, };