208 lines
7.5 KiB
TypeScript
208 lines
7.5 KiB
TypeScript
"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<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
|
|
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 <DialogPrimitive.Root {...props} open={effectiveOpen} onOpenChange={guardedOnOpenChange} modal={effectiveModal} />;
|
|
};
|
|
Dialog.displayName = "Dialog";
|
|
|
|
const DialogTrigger = DialogPrimitive.Trigger;
|
|
|
|
const DialogPortal = DialogPrimitive.Portal;
|
|
|
|
const DialogClose = DialogPrimitive.Close;
|
|
|
|
const DialogOverlay = React.forwardRef<
|
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
|
>(({ className, ...props }, ref) => (
|
|
<DialogPrimitive.Overlay
|
|
ref={ref}
|
|
className={cn(
|
|
"fixed inset-0 z-999 bg-black/60",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
));
|
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
|
|
interface ScopedDialogContentProps
|
|
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
|
/** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */
|
|
container?: HTMLElement | null;
|
|
/** 탭 비활성 시 포탈 내용 숨김 */
|
|
hidden?: boolean;
|
|
}
|
|
|
|
const DialogContent = React.forwardRef<
|
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
|
ScopedDialogContentProps
|
|
>(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, onFocusOutside, style, ...props }, ref) => {
|
|
const autoContainer = useModalPortal();
|
|
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
|
|
const scoped = !!container;
|
|
|
|
// 모달 자동 검증용 내부 ref
|
|
const internalRef = React.useRef<HTMLDivElement>(null);
|
|
const mergedRef = React.useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
internalRef.current = node;
|
|
if (typeof ref === "function") ref(node);
|
|
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
},
|
|
[ref],
|
|
);
|
|
useDialogAutoValidation(internalRef);
|
|
|
|
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 (
|
|
<DialogPortal container={container ?? undefined}>
|
|
<div
|
|
className={scoped ? "absolute inset-0 z-999 flex items-center justify-center overflow-hidden p-4" : undefined}
|
|
style={hiddenProp ? { display: "none" } : undefined}
|
|
>
|
|
{scoped ? (
|
|
<div className="absolute inset-0 bg-black/60" />
|
|
) : (
|
|
<DialogPrimitive.Overlay className="fixed inset-0 z-999 bg-black/60" />
|
|
)}
|
|
<DialogPrimitive.Content
|
|
ref={mergedRef}
|
|
onInteractOutside={handleInteractOutside}
|
|
onFocusOutside={handleFocusOutside}
|
|
className={cn(
|
|
scoped
|
|
? "bg-background relative z-1 flex w-full max-w-lg max-h-full flex-col gap-4 border p-6 shadow-lg sm:rounded-lg"
|
|
: "bg-background fixed top-[50%] left-[50%] z-1000 flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
|
|
className,
|
|
scoped && "max-h-full",
|
|
)}
|
|
style={adjustedStyle}
|
|
{...props}
|
|
>
|
|
{children}
|
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
|
<X className="h-4 w-4" />
|
|
<span className="sr-only">Close</span>
|
|
</DialogPrimitive.Close>
|
|
</DialogPrimitive.Content>
|
|
</div>
|
|
</DialogPortal>
|
|
);
|
|
});
|
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
|
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left shrink-0", className)} {...props} />
|
|
);
|
|
DialogHeader.displayName = "DialogHeader";
|
|
|
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
|
<div data-slot="dialog-footer" className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 shrink-0", className)} {...props} />
|
|
);
|
|
DialogFooter.displayName = "DialogFooter";
|
|
|
|
const DialogTitle = React.forwardRef<
|
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
|
>(({ className, ...props }, ref) => (
|
|
<DialogPrimitive.Title
|
|
ref={ref}
|
|
className={cn("text-lg leading-none font-semibold tracking-tight", className)}
|
|
{...props}
|
|
/>
|
|
));
|
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
|
|
|
const DialogDescription = React.forwardRef<
|
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
|
>(({ className, ...props }, ref) => (
|
|
<DialogPrimitive.Description ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
|
|
));
|
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
|
|
|
export {
|
|
Dialog,
|
|
DialogPortal,
|
|
DialogOverlay,
|
|
DialogClose,
|
|
DialogTrigger,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogFooter,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
};
|