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:
parent
a0e3147b47
commit
dc04bd162a
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue