refactor: Enhance modal and tab handling in ScreenModal and TabContent components

- Removed unnecessary variable `isTabActive` in ScreenModal for cleaner state management.
- Updated `useEffect` dependencies to include `tabId` for accurate modal behavior.
- Improved tab content caching logic to ensure scroll positions and form states are correctly saved and restored.
- Enhanced dialog handling to prevent unintended closures when tabs are inactive, ensuring a smoother user experience.

Made-with: Cursor
This commit is contained in:
syc0123 2026-02-27 16:01:23 +09:00
parent a0e3147b47
commit dc04bd162a
4 changed files with 147 additions and 41 deletions

View File

@ -46,7 +46,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const splitPanelContext = useSplitPanelContext();
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
const [modalState, setModalState] = useState<ScreenModalState>({
isOpen: false,
@ -855,7 +854,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
} else {
handleCloseInternal();
}
}, []);
}, [tabId]);
// 확인 후 실제로 모달을 닫는 함수
const handleConfirmClose = useCallback(() => {
@ -993,7 +992,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<Dialog
open={modalState.isOpen}
onOpenChange={(open) => {
// X 버튼 클릭 시에도 확인 다이얼로그 표시
if (!open) {
handleCloseAttempt();
}

View File

@ -96,6 +96,7 @@ export function TabContent() {
const prevId = prevActiveTabIdRef.current;
// 이전 활성 탭의 스크롤 + 폼 상태 저장
// 키를 항상 포함하여 이전 캐시의 오래된 값이 병합으로 살아남지 않도록 함
if (prevId && prevId !== activeTabId) {
const tabMap = lastScrollMapRef.current.get(prevId);
const scrollPositions =
@ -105,8 +106,8 @@ export function TabContent() {
const prevEl = scrollRefsMap.current.get(prevId);
const formFields = captureFormState(prevEl ?? null);
saveTabCacheImmediate(prevId, {
...(scrollPositions && { scrollPositions }),
...(formFields && { domFormFields: formFields }),
scrollPositions,
domFormFields: formFields ?? undefined,
});
}
@ -148,8 +149,8 @@ export function TabContent() {
const finalPositions = scrollPositions || trackedPositions;
const formFields = captureFormState(el ?? null);
saveTabCacheImmediate(currentActiveId, {
...(finalPositions && { scrollPositions: finalPositions }),
...(formFields && { domFormFields: formFields }),
scrollPositions: finalPositions,
domFormFields: formFields ?? undefined,
});
};

View File

@ -2,6 +2,7 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
@ -9,18 +10,54 @@ import { useModalPortal } from "@/lib/modalPortalRef";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
// AlertDialog: 비활성 탭이면 자동으로 open={false} 처리
/**
* Context.
* scoped=true AlertDialogPrimitive DialogPrimitive .
*/
const ScopedAlertCtx = React.createContext(false);
const AlertDialog: React.FC<React.ComponentProps<typeof AlertDialogPrimitive.Root>> = ({
open,
children,
onOpenChange,
...props
}) => {
const autoContainer = useModalPortal();
const scoped = !!autoContainer;
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
const isTabActiveRef = React.useRef(isTabActive);
isTabActiveRef.current = isTabActive;
const effectiveOpen = open != null ? open && isTabActive : undefined;
return <AlertDialogPrimitive.Root {...props} open={effectiveOpen} />;
const guardedOnOpenChange = React.useCallback(
(newOpen: boolean) => {
if (scoped && !newOpen && !isTabActiveRef.current) return;
onOpenChange?.(newOpen);
},
[scoped, onOpenChange],
);
if (scoped) {
return (
<ScopedAlertCtx.Provider value={true}>
<DialogPrimitive.Root open={effectiveOpen} onOpenChange={guardedOnOpenChange} modal={false}>
{children}
</DialogPrimitive.Root>
</ScopedAlertCtx.Provider>
);
}
return (
<ScopedAlertCtx.Provider value={false}>
<AlertDialogPrimitive.Root {...props} open={effectiveOpen} onOpenChange={guardedOnOpenChange}>
{children}
</AlertDialogPrimitive.Root>
</ScopedAlertCtx.Provider>
);
};
AlertDialog.displayName = "AlertDialog";
@ -45,9 +82,7 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
interface ScopedAlertDialogContentProps
extends React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> {
/** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */
container?: HTMLElement | null;
/** 탭 비활성 시 포탈 내용 숨김 */
hidden?: boolean;
}
@ -57,31 +92,62 @@ const AlertDialogContent = React.forwardRef<
>(({ className, container: explicitContainer, hidden: hiddenProp, style, ...props }, ref) => {
const autoContainer = useModalPortal();
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = !!container;
const scoped = React.useContext(ScopedAlertCtx);
const adjustedStyle = scoped && style
? { ...style, maxHeight: undefined, maxWidth: undefined }
: style;
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;
}
}
e.preventDefault();
},
[scoped, container],
);
if (scoped) {
return (
<DialogPrimitive.Portal container={container ?? undefined}>
<div
className="absolute inset-0 z-1050 flex items-center justify-center overflow-hidden p-4"
style={hiddenProp ? { display: "none" } : undefined}
>
<div className="absolute inset-0 bg-black/80" />
<DialogPrimitive.Content
ref={ref}
onInteractOutside={handleInteractOutside}
onFocusOutside={(e: any) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
className={cn(
"bg-background relative z-1 grid w-full max-w-lg max-h-full gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
)}
style={adjustedStyle}
{...props}
/>
</div>
</DialogPrimitive.Portal>
);
}
return (
<AlertDialogPortal container={container ?? undefined}>
<div
className={scoped ? "absolute inset-0 z-1050 flex items-center justify-center overflow-hidden p-4" : undefined}
style={hiddenProp ? { display: "none" } : undefined}
>
{scoped ? (
<div className="absolute inset-0 bg-black/80" />
) : (
<AlertDialogPrimitive.Overlay className="fixed inset-0 z-1050 bg-black/80" />
)}
<AlertDialogPrimitive.Overlay className="fixed inset-0 z-1050 bg-black/80" />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
scoped
? "bg-background relative z-1 grid w-full max-w-lg max-h-full gap-4 border p-6 shadow-lg sm:rounded-lg"
: "bg-background fixed top-[50%] left-[50%] z-1100 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
"bg-background fixed top-[50%] left-[50%] z-1100 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
className,
scoped && "max-h-full",
)}
style={adjustedStyle}
{...props}
@ -105,37 +171,47 @@ AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
>(({ className, ...props }, ref) => {
const scoped = React.useContext(ScopedAlertCtx);
const Comp = scoped ? DialogPrimitive.Title : AlertDialogPrimitive.Title;
return <Comp ref={ref} className={cn("text-lg font-semibold", className)} {...props} />;
});
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
));
>(({ className, ...props }, ref) => {
const scoped = React.useContext(ScopedAlertCtx);
const Comp = scoped ? DialogPrimitive.Description : AlertDialogPrimitive.Description;
return <Comp ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />;
});
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
>(({ className, ...props }, ref) => {
const scoped = React.useContext(ScopedAlertCtx);
const Comp = scoped ? DialogPrimitive.Close : AlertDialogPrimitive.Action;
return <Comp ref={ref} className={cn(buttonVariants(), className)} {...props} />;
});
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
>(({ className, ...props }, ref) => {
const scoped = React.useContext(ScopedAlertCtx);
const Comp = scoped ? DialogPrimitive.Close : AlertDialogPrimitive.Cancel;
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
);
});
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {

View File

@ -13,17 +13,35 @@ import { useTabStore } from "@/stores/tabStore";
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;
const effectiveModal = modal !== undefined ? modal : !autoContainer ? undefined : false;
// 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;
return <DialogPrimitive.Root {...props} open={effectiveOpen} modal={effectiveModal} />;
// 비활성 탭에서 발생하는 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";
@ -59,7 +77,7 @@ interface ScopedDialogContentProps
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
ScopedDialogContentProps
>(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, style, ...props }, ref) => {
>(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, onFocusOutside, style, ...props }, ref) => {
const autoContainer = useModalPortal();
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = !!container;
@ -78,6 +96,18 @@ const DialogContent = React.forwardRef<
[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 }
@ -97,6 +127,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
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"