feat: Enhance dialog and tab management for improved user experience

- Implemented tab state persistence to maintain modal states across tab switches.
- Updated ScreenModal to manage modal states independently for each tab.
- Enhanced AppLayout to ensure dialog overlays do not obstruct sidebar and tab bar interactions.
- Improved accessibility by allowing interaction with the main content while dialogs are open.
- Introduced CSS variables for dynamic positioning of dialog overlays based on device type.
This commit is contained in:
syc0123 2026-02-26 10:27:30 +09:00
parent 72d9e55159
commit 601ba08e44
8 changed files with 432 additions and 80 deletions

View File

@ -206,6 +206,16 @@ button:disabled {
backdrop-filter: none !important; backdrop-filter: none !important;
} }
/* ===== Dialog 열릴 때 사이드바/탭바/컨텐츠 상호작용 유지 ===== */
/* Radix Dialog가 body에 pointer-events:none을 걸어도 네비게이션 영역과 메인 컨텐츠는 클릭 가능 */
/* 비활성 탭에 모달이 열려있어도 활성 탭 컨텐츠를 조작할 수 있도록 main 포함 */
[data-sidebar],
[data-tabbar],
main {
pointer-events: auto !important;
}
/* ===== Accessibility - Focus Styles ===== */ /* ===== Accessibility - Focus Styles ===== */
/* 모든 인터랙티브 요소에 대한 포커스 스타일 */ /* 모든 인터랙티브 요소에 대한 포커스 스타일 */
button:focus-visible, button:focus-visible,

View File

@ -25,6 +25,7 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { DialogPortalContainerContext } from "@/contexts/DialogPortalContext";
interface ScreenModalState { interface ScreenModalState {
isOpen: boolean; isOpen: boolean;
@ -34,6 +35,20 @@ interface ScreenModalState {
size: "sm" | "md" | "lg" | "xl"; size: "sm" | "md" | "lg" | "xl";
} }
// TSP (Tab State Persistence) - 탭별 모달 상태 독립 유지
const MODAL_TSP_PREFIX = "tsp-screen-modal-";
const getModalTspKey = (tabId: string) => `${MODAL_TSP_PREFIX}${tabId}`;
interface PersistedModalData {
modalState: ScreenModalState;
formData: Record<string, any>;
originalData: Record<string, any> | null;
selectedData: Record<string, any>[];
modalOpenTabId: string | null;
continuousMode: boolean;
formDataChanged: boolean;
}
interface ScreenModalProps { interface ScreenModalProps {
className?: string; className?: string;
} }
@ -147,6 +162,158 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용) // 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
const modalOpenedAtRef = React.useRef<number>(0); const modalOpenedAtRef = React.useRef<number>(0);
// 탭별 모달 상태 독립 관리
const modalOpenTabIdRef = useRef<string | null>(null);
const [isTabHidden, setIsTabHidden] = useState(false);
const isTabHiddenRef = useRef(false);
isTabHiddenRef.current = isTabHidden;
// 탭별 모달 상태 인메모리 캐시 (탭 전환 시 각 탭의 모달을 독립적으로 유지)
const tabModalCacheRef = useRef<Map<string, PersistedModalData>>(new Map());
// ScreenModal 전용 포탈 컨테이너 - Dialog/AlertDialog가 이 안으로 포탈됨
const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(null);
// isTabHidden 변경 시 포탈 컨테이너의 display를 직접 제어
useEffect(() => {
if (portalContainer) {
portalContainer.style.display = isTabHidden ? "none" : "";
}
}, [isTabHidden, portalContainer]);
// 탭 전환 시 모달 상태 스왑: 현재 탭 → 캐시 저장, 새 탭 → 캐시 복원
useEffect(() => {
const handleTabSwitch = (e: Event) => {
const { tabId: newTabId } = (e as CustomEvent).detail;
const s = latestTspRef.current;
const currentTabId = modalOpenTabIdRef.current;
// 같은 탭이면: 숨겨진 모달이 있으면 다시 보여주고 종료
if (currentTabId === newTabId) {
if (isTabHiddenRef.current && s.modalState.isOpen) {
setIsTabHidden(false);
}
return;
}
// 현재 모달이 열려있으면 현재 탭 캐시에 저장
if (s.modalState.isOpen && currentTabId) {
const cached: PersistedModalData = {
modalState: s.modalState,
formData: s.formData,
originalData: s.originalData,
selectedData: s.selectedData,
modalOpenTabId: currentTabId,
continuousMode: s.continuousMode,
formDataChanged: formDataChangedRef.current,
};
tabModalCacheRef.current.set(currentTabId, cached);
try { sessionStorage.setItem(getModalTspKey(currentTabId), JSON.stringify(cached)); } catch {}
}
// 새 탭에 캐시된 모달이 있으면 복원
const newTabCache = tabModalCacheRef.current.get(newTabId);
if (newTabCache && newTabCache.modalState?.isOpen) {
isRestoringRef.current = true;
setModalState(newTabCache.modalState);
setFormData(newTabCache.formData || {});
setOriginalData(newTabCache.originalData || null);
setSelectedData(newTabCache.selectedData || []);
setContinuousMode(newTabCache.continuousMode || false);
modalOpenTabIdRef.current = newTabId;
formDataChangedRef.current = newTabCache.formDataChanged || false;
setIsTabHidden(false);
} else if (s.modalState.isOpen) {
// 새 탭에 모달이 없으면 현재 모달 숨김
setIsTabHidden(true);
}
};
window.addEventListener("tabSwitch", handleTabSwitch);
return () => window.removeEventListener("tabSwitch", handleTabSwitch);
}, []);
// ─── TSP: latestTspRef (최신 상태 참조 — 이벤트 핸들러/beforeunload에서 사용) ───
const latestTspRef = useRef({ modalState, formData, originalData, selectedData, continuousMode });
latestTspRef.current = { modalState, formData, originalData, selectedData, continuousMode };
const isRestoringRef = useRef(false);
// ─── TSP: 새로고침 후 비활성 탭 모달만 복원 ───
useEffect(() => {
// __activeTabId가 설정된 후 실행 (TabContext 초기화 대기)
setTimeout(() => {
const currentTabId = (window as any).__activeTabId;
for (let i = sessionStorage.length - 1; i >= 0; i--) {
const key = sessionStorage.key(i);
if (!key?.startsWith(MODAL_TSP_PREFIX)) continue;
try {
const data: PersistedModalData = JSON.parse(sessionStorage.getItem(key)!);
if (!data.modalOpenTabId || !data.modalState?.isOpen) continue;
if (data.modalOpenTabId === currentTabId) {
// 활성 탭 모달은 삭제 (새로고침 = 현재 화면 초기화)
sessionStorage.removeItem(key);
} else {
// 비활성 탭 모달만 캐시에 로드
tabModalCacheRef.current.set(data.modalOpenTabId, data);
}
} catch {
sessionStorage.removeItem(key!);
}
}
}, 0);
}, []);
// ─── TSP: 상태 변경 시 현재 탭의 sessionStorage에 저장 (300ms 디바운스) ───
useEffect(() => {
const tabId = modalOpenTabIdRef.current;
if (!modalState.isOpen || !tabId) return;
const timer = setTimeout(() => {
try {
const data: PersistedModalData = {
modalState,
formData,
originalData,
selectedData,
modalOpenTabId: tabId,
continuousMode,
formDataChanged: formDataChangedRef.current,
};
sessionStorage.setItem(getModalTspKey(tabId), JSON.stringify(data));
tabModalCacheRef.current.set(tabId, data);
} catch { /* sessionStorage 용량 초과 무시 */ }
}, 300);
return () => clearTimeout(timer);
}, [modalState, formData, originalData, selectedData, continuousMode]);
// ─── TSP: beforeunload 시 즉시 저장 (디바운스 대기 중인 데이터 유실 방지) ───
useEffect(() => {
const flushTsp = () => {
const s = latestTspRef.current;
const tabId = modalOpenTabIdRef.current;
if (!s.modalState.isOpen || !tabId) return;
try {
const data: PersistedModalData = {
...s,
modalOpenTabId: tabId,
formDataChanged: formDataChangedRef.current,
};
sessionStorage.setItem(getModalTspKey(tabId), JSON.stringify(data));
} catch { /* 무시 */ }
// 다른 탭의 캐시된 모달도 flush
tabModalCacheRef.current.forEach((cached, cachedTabId) => {
if (cachedTabId === tabId) return;
try { sessionStorage.setItem(getModalTspKey(cachedTabId), JSON.stringify(cached)); } catch {}
});
};
window.addEventListener("beforeunload", flushTsp);
return () => window.removeEventListener("beforeunload", flushTsp);
}, []);
// 🆕 채번 필드 수동 입력 값 변경 이벤트 리스너 // 🆕 채번 필드 수동 입력 값 변경 이벤트 리스너
useEffect(() => { useEffect(() => {
const handleNumberingValueChanged = (event: CustomEvent) => { const handleNumberingValueChanged = (event: CustomEvent) => {
@ -192,6 +359,28 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 🆕 모달 열린 시간 기록 // 🆕 모달 열린 시간 기록
modalOpenedAtRef.current = Date.now(); modalOpenedAtRef.current = Date.now();
const newTabId = (window as any).__activeTabId || null;
const oldTabId = modalOpenTabIdRef.current;
// 다른 탭에서 이미 모달이 열려있으면 현재 상태를 해당 탭 캐시에 저장
if (oldTabId && oldTabId !== newTabId && latestTspRef.current.modalState.isOpen) {
const cached: PersistedModalData = {
modalState: latestTspRef.current.modalState,
formData: latestTspRef.current.formData,
originalData: latestTspRef.current.originalData,
selectedData: latestTspRef.current.selectedData,
modalOpenTabId: oldTabId,
continuousMode: latestTspRef.current.continuousMode,
formDataChanged: formDataChangedRef.current,
};
tabModalCacheRef.current.set(oldTabId, cached);
try { sessionStorage.setItem(getModalTspKey(oldTabId), JSON.stringify(cached)); } catch {}
}
// 모달이 열린 탭 ID 기록
modalOpenTabIdRef.current = newTabId;
setIsTabHidden(false);
// 폼 변경 추적 초기화 // 폼 변경 추적 초기화
formDataChangedRef.current = false; formDataChangedRef.current = false;
@ -402,6 +591,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setSelectedData([]); // 🆕 선택된 데이터 초기화 setSelectedData([]); // 🆕 선택된 데이터 초기화
setContinuousMode(false); setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false"); localStorage.setItem("screenModal_continuousMode", "false");
const closingTabId = modalOpenTabIdRef.current;
modalOpenTabIdRef.current = null;
setIsTabHidden(false);
if (closingTabId) {
tabModalCacheRef.current.delete(closingTabId);
try { sessionStorage.removeItem(getModalTspKey(closingTabId)); } catch {}
}
}; };
// 저장 성공 이벤트 처리 (연속 등록 모드 지원) // 저장 성공 이벤트 처리 (연속 등록 모드 지원)
@ -475,16 +671,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
layoutData = await screenApi.getLayout(screenId); layoutData = await screenApi.getLayout(screenId);
} }
// 🆕 URL 파라미터 확인 (수정 모드) // TSP 복원 중이면 URL 파라미터 기반 데이터 fetch 스킵 (이미 복원된 formData 유지)
if (typeof window !== "undefined") { if (typeof window !== "undefined" && !isRestoringRef.current) {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const mode = urlParams.get("mode"); const mode = urlParams.get("mode");
const editId = urlParams.get("editId"); const editId = urlParams.get("editId");
const tableName = urlParams.get("tableName") || screenInfo.tableName; const tableName = urlParams.get("tableName") || screenInfo.tableName;
const groupByColumnsParam = urlParams.get("groupByColumns"); const groupByColumnsParam = urlParams.get("groupByColumns");
const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명 const primaryKeyColumn = urlParams.get("primaryKeyColumn");
// 수정 모드이고 editId가 있으면 해당 레코드 조회
if (mode === "edit" && editId && tableName) { if (mode === "edit" && editId && tableName) {
try { try {
// groupByColumns 파싱 // groupByColumns 파싱
@ -556,6 +751,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
} }
} }
// TSP 복원 플래그 해제
isRestoringRef.current = false;
// screenApi는 직접 데이터를 반환하므로 .success 체크 불필요 // screenApi는 직접 데이터를 반환하므로 .success 체크 불필요
if (screenInfo && layoutData) { if (screenInfo && layoutData) {
const components = layoutData.components || []; const components = layoutData.components || [];
@ -882,6 +1080,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setConditionalLayers([]); // 🆕 조건부 레이어 초기화 setConditionalLayers([]); // 🆕 조건부 레이어 초기화
setContinuousMode(false); setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false"); localStorage.setItem("screenModal_continuousMode", "false");
const closingTabId = modalOpenTabIdRef.current;
if (closingTabId) {
tabModalCacheRef.current.delete(closingTabId);
try { sessionStorage.removeItem(getModalTspKey(closingTabId)); } catch {}
}
}; };
// 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용) // 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용)
@ -889,28 +1092,31 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터 // 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
const getModalStyle = () => { const getModalStyle = () => {
const overlayLeft = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--dialog-overlay-left") || "0"
);
const overlayTop = parseFloat(
getComputedStyle(document.documentElement).getPropertyValue("--dialog-overlay-top") || "0"
);
const availableWidth = window.innerWidth - overlayLeft;
const availableMaxW = `calc(100vw - ${overlayLeft}px - 32px)`;
const availableMaxH = `calc(100dvh - ${overlayTop}px - 16px)`;
if (!screenDimensions) { if (!screenDimensions) {
console.log("⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용");
return { return {
className: "w-fit min-w-[400px] max-w-4xl overflow-hidden", className: "w-fit min-w-[400px] max-w-4xl overflow-hidden",
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" }, style: { padding: 0, gap: 0, maxHeight: availableMaxH, maxWidth: availableMaxW },
}; };
} }
const finalWidth = Math.min(screenDimensions.width, window.innerWidth * 0.98); const finalWidth = Math.min(screenDimensions.width, availableWidth * 0.98);
console.log("✅ [ScreenModal] getModalStyle: 해상도 적용됨", {
screenDimensions,
finalWidth: `${finalWidth}px`,
viewportWidth: window.innerWidth,
});
return { return {
className: "overflow-hidden", className: "overflow-hidden",
style: { style: {
width: `${finalWidth}px`, width: `${finalWidth}px`,
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한 maxHeight: availableMaxH,
maxHeight: "calc(100dvh - 8px)", maxWidth: availableMaxW,
maxWidth: "98vw",
padding: 0, padding: 0,
gap: 0, gap: 0,
}, },
@ -979,11 +1185,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
]); ]);
return ( return (
<>
<div ref={setPortalContainer} />
<DialogPortalContainerContext.Provider value={portalContainer}>
<Dialog <Dialog
open={modalState.isOpen} open={modalState.isOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
// X 버튼 클릭 시에도 확인 다이얼로그 표시 if (!open && !isTabHidden) {
if (!open) {
handleCloseAttempt(); handleCloseAttempt();
} }
}} }}
@ -991,14 +1199,14 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<DialogContent <DialogContent
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`} className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
style={modalStyle.style} style={modalStyle.style}
// 바깥 클릭 시 바로 닫히지 않도록 방지
onInteractOutside={(e) => { onInteractOutside={(e) => {
e.preventDefault(); e.preventDefault();
if (isTabHidden) return;
handleCloseAttempt(); handleCloseAttempt();
}} }}
// ESC 키 누를 때도 바로 닫히지 않도록 방지
onEscapeKeyDown={(e) => { onEscapeKeyDown={(e) => {
e.preventDefault(); e.preventDefault();
if (isTabHidden) return;
handleCloseAttempt(); handleCloseAttempt();
}} }}
> >
@ -1310,6 +1518,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</Dialog> </Dialog>
</DialogPortalContainerContext.Provider>
</>
); );
}; };

View File

@ -302,6 +302,22 @@ function AppLayoutInner({ children }: AppLayoutProps) {
return () => window.removeEventListener("resize", checkIsMobile); return () => window.removeEventListener("resize", checkIsMobile);
}, []); }, []);
// 모달 오버레이가 사이드바/탭바 영역을 덮지 않도록 CSS 변수 설정
useEffect(() => {
const root = document.documentElement;
if (!isMobile) {
root.style.setProperty("--dialog-overlay-left", "200px");
root.style.setProperty("--dialog-overlay-top", "36px");
} else {
root.style.removeProperty("--dialog-overlay-left");
root.style.removeProperty("--dialog-overlay-top");
}
return () => {
root.style.removeProperty("--dialog-overlay-left");
root.style.removeProperty("--dialog-overlay-top");
};
}, [isMobile]);
// 프로필 관련 로직 // 프로필 관련 로직
const { const {
isModalOpen, isModalOpen,
@ -596,6 +612,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
{/* 왼쪽 사이드바 */} {/* 왼쪽 사이드바 */}
<aside <aside
data-sidebar
className={`${ className={`${
isMobile isMobile
? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40 h-[calc(100vh-56px)]" ? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40 h-[calc(100vh-56px)]"
@ -758,7 +775,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
{/* 가운데 컨텐츠 영역 */} {/* 가운데 컨텐츠 영역 */}
<main className={`flex min-w-0 flex-1 flex-col bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}> <main className={`flex min-w-0 flex-1 flex-col bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
{/* 탭 바 (데스크톱에서만 항상 표시) */} {/* 탭 바 (데스크톱에서만 항상 표시) */}
{!isMobile && <TabBar />} {!isMobile && (
<div data-tabbar className="relative bg-slate-50">
<TabBar />
</div>
)}
{/* 콘텐츠 영역 */} {/* 콘텐츠 영역 */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">

View File

@ -1,8 +1,9 @@
"use client"; "use client";
import React, { Suspense, useRef } from "react"; import React, { Suspense, useRef, useState } from "react";
import { Loader2, Inbox } from "lucide-react"; import { Loader2, Inbox } from "lucide-react";
import { useTab, TabItem } from "@/contexts/TabContext"; import { useTab, TabItem } from "@/contexts/TabContext";
import { DialogPortalContainerContext } from "@/contexts/DialogPortalContext";
import { ScreenViewPageEmbeddable } from "@/app/(main)/screens/[screenId]/page"; import { ScreenViewPageEmbeddable } from "@/app/(main)/screens/[screenId]/page";
function TabLoadingFallback() { function TabLoadingFallback() {
@ -32,33 +33,53 @@ function EmptyTabState() {
); );
} }
/**
* - Dialog
* display:none이면
*/
function TabPanel({
isActive,
isMounted,
children,
}: {
isActive: boolean;
isMounted: boolean;
children: React.ReactNode;
}) {
const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(null);
return (
<div
className="h-full w-full"
style={{ display: isActive ? "block" : "none" }}
>
<div ref={setPortalContainer} />
<DialogPortalContainerContext.Provider value={portalContainer}>
{isMounted ? children : null}
</DialogPortalContainerContext.Provider>
</div>
);
}
export function TabContent() { export function TabContent() {
const { tabs, activeTabId, refreshKeys } = useTab(); const { tabs, activeTabId, refreshKeys } = useTab();
// 탭 순서 변경(드래그 리오더링) 시 DOM 재배치를 방지하기 위해
// 탭이 추가된 순서(id 기준)로 고정 렌더링
const stableOrderRef = useRef<TabItem[]>([]); const stableOrderRef = useRef<TabItem[]>([]);
// F5 이후 활성 탭만 마운트하고, 비활성 탭은 클릭 시 마운트 (Lazy Mount)
const mountedTabIdsRef = useRef<Set<string>>( const mountedTabIdsRef = useRef<Set<string>>(
new Set(activeTabId ? [activeTabId] : []) new Set(activeTabId ? [activeTabId] : [])
); );
// 활성 탭을 마운트 목록에 추가
if (activeTabId && !mountedTabIdsRef.current.has(activeTabId)) { if (activeTabId && !mountedTabIdsRef.current.has(activeTabId)) {
mountedTabIdsRef.current.add(activeTabId); mountedTabIdsRef.current.add(activeTabId);
} }
// 새 탭 추가 시 stableOrder에 append, 닫힌 탭은 제거
const currentIds = new Set(tabs.map(t => t.id)); const currentIds = new Set(tabs.map(t => t.id));
const stableIds = new Set(stableOrderRef.current.map(t => t.id)); const stableIds = new Set(stableOrderRef.current.map(t => t.id));
// 닫힌 탭 제거
stableOrderRef.current = stableOrderRef.current.filter(t => currentIds.has(t.id)); stableOrderRef.current = stableOrderRef.current.filter(t => currentIds.has(t.id));
// 닫힌 탭을 마운트 목록에서도 제거
for (const id of mountedTabIdsRef.current) { for (const id of mountedTabIdsRef.current) {
if (!currentIds.has(id)) mountedTabIdsRef.current.delete(id); if (!currentIds.has(id)) mountedTabIdsRef.current.delete(id);
} }
// 새로 추가된 탭 append
for (const tab of tabs) { for (const tab of tabs) {
if (!stableIds.has(tab.id)) { if (!stableIds.has(tab.id)) {
stableOrderRef.current.push(tab); stableOrderRef.current.push(tab);
@ -74,20 +95,18 @@ export function TabContent() {
return ( return (
<> <>
{stableTabs.map((tab) => ( {stableTabs.map((tab) => (
<div <TabPanel
key={`${tab.id}-${refreshKeys[tab.id] || 0}`} key={`${tab.id}-${refreshKeys[tab.id] || 0}`}
className="h-full w-full" isActive={tab.id === activeTabId}
style={{ display: tab.id === activeTabId ? "block" : "none" }} isMounted={mountedTabIdsRef.current.has(tab.id)}
> >
{mountedTabIdsRef.current.has(tab.id) ? (
<Suspense fallback={<TabLoadingFallback />}> <Suspense fallback={<TabLoadingFallback />}>
<ScreenViewPageEmbeddable <ScreenViewPageEmbeddable
screenId={tab.screenId} screenId={tab.screenId}
menuObjid={tab.menuObjid} menuObjid={tab.menuObjid}
/> />
</Suspense> </Suspense>
) : null} </TabPanel>
</div>
))} ))}
</> </>
); );

View File

@ -5,6 +5,7 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { useDialogPortalContainer } from "@/contexts/DialogPortalContext";
const AlertDialog = AlertDialogPrimitive.Root; const AlertDialog = AlertDialogPrimitive.Root;
@ -15,12 +16,19 @@ const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef< const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>, React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay> React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => ( >(({ className, style, ...props }, ref) => (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
className={cn( className={cn(
"fixed inset-0 z-[999] bg-black/80", "fixed z-[999] bg-black/80",
className, className,
)} )}
style={{
top: "var(--dialog-overlay-top, 0px)",
left: "var(--dialog-overlay-left, 0px)",
right: 0,
bottom: 0,
...style,
}}
{...props} {...props}
ref={ref} ref={ref}
/> />
@ -30,19 +38,28 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef< const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>, React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => ( >(({ className, style, ...props }, ref) => {
<AlertDialogPortal> const portalContainer = useDialogPortalContainer();
return (
<AlertDialogPortal container={portalContainer || undefined}>
<AlertDialogOverlay /> <AlertDialogOverlay />
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"bg-background fixed top-[50%] left-[50%] z-[1000] 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 z-[1002] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
className, className,
)} )}
style={{
top: "calc(var(--dialog-overlay-top, 0px) + (100dvh - var(--dialog-overlay-top, 0px)) / 2)",
left: "calc(var(--dialog-overlay-left, 0px) + (100vw - var(--dialog-overlay-left, 0px)) / 2)",
...style,
}}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
)); );
});
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (

View File

@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDialogPortalContainer } from "@/contexts/DialogPortalContext";
const Dialog = DialogPrimitive.Root; const Dialog = DialogPrimitive.Root;
@ -17,30 +18,76 @@ const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef< const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => ( >(({ className, style, ...props }, ref) => (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn("fixed z-[999] bg-black/60", className)}
"fixed inset-0 z-[999] bg-black/60", style={{
className, top: "var(--dialog-overlay-top, 0px)",
)} left: "var(--dialog-overlay-left, 0px)",
right: 0,
bottom: 0,
...style,
}}
{...props} {...props}
/> />
)); ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
/**
* /
* / Dialog의 "외부 상호작용"
*/
function isNavigationAreaClick(e: { target: EventTarget | null }): boolean {
const target = e.target as HTMLElement | null;
if (!target?.closest) return false;
return !!(target.closest("[data-sidebar]") || target.closest("[data-tabbar]"));
}
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => ( >(({ className, children, onInteractOutside, onPointerDownOutside, style, ...props }, ref) => {
<DialogPortal> const portalContainer = useDialogPortalContainer();
const handleInteractOutside = React.useCallback(
(e: Event & { preventDefault: () => void }) => {
if (isNavigationAreaClick(e)) {
e.preventDefault();
return;
}
onInteractOutside?.(e as never);
},
[onInteractOutside],
);
const handlePointerDownOutside = React.useCallback(
(e: Event & { preventDefault: () => void }) => {
if (isNavigationAreaClick(e)) {
e.preventDefault();
return;
}
onPointerDownOutside?.(e as never);
},
[onPointerDownOutside],
);
return (
<DialogPortal container={portalContainer || undefined}>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"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", "bg-background fixed z-[1002] 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, className,
)} )}
style={{
top: "calc(var(--dialog-overlay-top, 0px) + (100dvh - var(--dialog-overlay-top, 0px)) / 2)",
left: "calc(var(--dialog-overlay-left, 0px) + (100vw - var(--dialog-overlay-left, 0px)) / 2)",
...style,
}}
onInteractOutside={handleInteractOutside}
onPointerDownOutside={handlePointerDownOutside}
{...props} {...props}
> >
{children} {children}
@ -50,7 +97,8 @@ const DialogContent = React.forwardRef<
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
)); );
});
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (

View File

@ -0,0 +1,17 @@
"use client";
import { createContext, useContext } from "react";
/**
* Dialog Portal
* -
* - display:none이
* - ( ) null body
*/
export const DialogPortalContainerContext = createContext<HTMLElement | null>(
null
);
export function useDialogPortalContainer() {
return useContext(DialogPortalContainerContext);
}

View File

@ -131,6 +131,16 @@ export function TabProvider({ children }: { children: ReactNode }) {
saveTabsToStorage(tabs, activeTabId); saveTabsToStorage(tabs, activeTabId);
}, [tabs, activeTabId, initialized]); }, [tabs, activeTabId, initialized]);
// activeTabId 변경 시 글로벌 이벤트 발행 (탭 외부 컴포넌트에서 탭 전환 감지용)
useEffect(() => {
if (activeTabId) {
(window as any).__activeTabId = activeTabId;
window.dispatchEvent(
new CustomEvent("tabSwitch", { detail: { tabId: activeTabId } }),
);
}
}, [activeTabId]);
const openTab = useCallback((tabData: Omit<TabItem, "id">, insertIndex?: number) => { const openTab = useCallback((tabData: Omit<TabItem, "id">, insertIndex?: number) => {
const tabId = generateTabId(tabData.screenId, tabData.menuObjid); const tabId = generateTabId(tabData.screenId, tabData.menuObjid);