ERP-node/frontend/components/ui/dialog.tsx

208 lines
7.5 KiB
TypeScript
Raw Normal View History

2025-08-21 09:41:46 +09:00
"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";
2025-08-21 09:41:46 +09:00
// 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";
2025-08-21 09:41:46 +09:00
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",
2025-08-21 09:41:46 +09:00
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface ScopedDialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
/** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */
container?: HTMLElement | null;
/** 탭 비활성 시 포탈 내용 숨김 */
hidden?: boolean;
}
2025-08-21 09:41:46 +09:00
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>
);
});
2025-08-21 09:41:46 +09:00
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
2025-12-05 10:46:10 +09:00
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left shrink-0", className)} {...props} />
2025-08-21 09:41:46 +09:00
);
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} />
2025-08-21 09:41:46 +09:00
);
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,
};