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:
parent
72d9e55159
commit
601ba08e44
|
|
@ -206,6 +206,16 @@ button:disabled {
|
|||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
/* ===== Dialog 열릴 때 사이드바/탭바/컨텐츠 상호작용 유지 ===== */
|
||||
/* Radix Dialog가 body에 pointer-events:none을 걸어도 네비게이션 영역과 메인 컨텐츠는 클릭 가능 */
|
||||
/* 비활성 탭에 모달이 열려있어도 활성 탭 컨텐츠를 조작할 수 있도록 main 포함 */
|
||||
[data-sidebar],
|
||||
[data-tabbar],
|
||||
main {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
|
||||
/* ===== Accessibility - Focus Styles ===== */
|
||||
/* 모든 인터랙티브 요소에 대한 포커스 스타일 */
|
||||
button:focus-visible,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
|||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||
import { DialogPortalContainerContext } from "@/contexts/DialogPortalContext";
|
||||
|
||||
interface ScreenModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -34,6 +35,20 @@ interface ScreenModalState {
|
|||
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 {
|
||||
className?: string;
|
||||
}
|
||||
|
|
@ -147,6 +162,158 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
|
||||
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(() => {
|
||||
const handleNumberingValueChanged = (event: CustomEvent) => {
|
||||
|
|
@ -192,6 +359,28 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 🆕 모달 열린 시간 기록
|
||||
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;
|
||||
|
||||
|
|
@ -402,6 +591,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
setSelectedData([]); // 🆕 선택된 데이터 초기화
|
||||
setContinuousMode(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);
|
||||
}
|
||||
|
||||
// 🆕 URL 파라미터 확인 (수정 모드)
|
||||
if (typeof window !== "undefined") {
|
||||
// TSP 복원 중이면 URL 파라미터 기반 데이터 fetch 스킵 (이미 복원된 formData 유지)
|
||||
if (typeof window !== "undefined" && !isRestoringRef.current) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const mode = urlParams.get("mode");
|
||||
const editId = urlParams.get("editId");
|
||||
const tableName = urlParams.get("tableName") || screenInfo.tableName;
|
||||
const groupByColumnsParam = urlParams.get("groupByColumns");
|
||||
const primaryKeyColumn = urlParams.get("primaryKeyColumn"); // 🆕 Primary Key 컬럼명
|
||||
const primaryKeyColumn = urlParams.get("primaryKeyColumn");
|
||||
|
||||
// 수정 모드이고 editId가 있으면 해당 레코드 조회
|
||||
if (mode === "edit" && editId && tableName) {
|
||||
try {
|
||||
// groupByColumns 파싱
|
||||
|
|
@ -556,6 +751,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
}
|
||||
}
|
||||
|
||||
// TSP 복원 플래그 해제
|
||||
isRestoringRef.current = false;
|
||||
|
||||
// screenApi는 직접 데이터를 반환하므로 .success 체크 불필요
|
||||
if (screenInfo && layoutData) {
|
||||
const components = layoutData.components || [];
|
||||
|
|
@ -882,6 +1080,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
setConditionalLayers([]); // 🆕 조건부 레이어 초기화
|
||||
setContinuousMode(false);
|
||||
localStorage.setItem("screenModal_continuousMode", "false");
|
||||
const closingTabId = modalOpenTabIdRef.current;
|
||||
if (closingTabId) {
|
||||
tabModalCacheRef.current.delete(closingTabId);
|
||||
try { sessionStorage.removeItem(getModalTspKey(closingTabId)); } catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용)
|
||||
|
|
@ -889,28 +1092,31 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
|
||||
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) {
|
||||
console.log("⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용");
|
||||
return {
|
||||
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);
|
||||
console.log("✅ [ScreenModal] getModalStyle: 해상도 적용됨", {
|
||||
screenDimensions,
|
||||
finalWidth: `${finalWidth}px`,
|
||||
viewportWidth: window.innerWidth,
|
||||
});
|
||||
const finalWidth = Math.min(screenDimensions.width, availableWidth * 0.98);
|
||||
|
||||
return {
|
||||
className: "overflow-hidden",
|
||||
style: {
|
||||
width: `${finalWidth}px`,
|
||||
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한
|
||||
maxHeight: "calc(100dvh - 8px)",
|
||||
maxWidth: "98vw",
|
||||
maxHeight: availableMaxH,
|
||||
maxWidth: availableMaxW,
|
||||
padding: 0,
|
||||
gap: 0,
|
||||
},
|
||||
|
|
@ -979,11 +1185,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={setPortalContainer} />
|
||||
<DialogPortalContainerContext.Provider value={portalContainer}>
|
||||
<Dialog
|
||||
open={modalState.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
// X 버튼 클릭 시에도 확인 다이얼로그 표시
|
||||
if (!open) {
|
||||
if (!open && !isTabHidden) {
|
||||
handleCloseAttempt();
|
||||
}
|
||||
}}
|
||||
|
|
@ -991,14 +1199,14 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
|
||||
style={modalStyle.style}
|
||||
// 바깥 클릭 시 바로 닫히지 않도록 방지
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
if (isTabHidden) return;
|
||||
handleCloseAttempt();
|
||||
}}
|
||||
// ESC 키 누를 때도 바로 닫히지 않도록 방지
|
||||
onEscapeKeyDown={(e) => {
|
||||
e.preventDefault();
|
||||
if (isTabHidden) return;
|
||||
handleCloseAttempt();
|
||||
}}
|
||||
>
|
||||
|
|
@ -1310,6 +1518,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
</DialogPortalContainerContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -302,6 +302,22 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
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 {
|
||||
isModalOpen,
|
||||
|
|
@ -596,6 +612,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
|
||||
{/* 왼쪽 사이드바 */}
|
||||
<aside
|
||||
data-sidebar
|
||||
className={`${
|
||||
isMobile
|
||||
? (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"}`}>
|
||||
{/* 탭 바 (데스크톱에서만 항상 표시) */}
|
||||
{!isMobile && <TabBar />}
|
||||
{!isMobile && (
|
||||
<div data-tabbar className="relative bg-slate-50">
|
||||
<TabBar />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 콘텐츠 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import React, { Suspense, useRef } from "react";
|
||||
import React, { Suspense, useRef, useState } from "react";
|
||||
import { Loader2, Inbox } from "lucide-react";
|
||||
import { useTab, TabItem } from "@/contexts/TabContext";
|
||||
import { DialogPortalContainerContext } from "@/contexts/DialogPortalContext";
|
||||
import { ScreenViewPageEmbeddable } from "@/app/(main)/screens/[screenId]/page";
|
||||
|
||||
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() {
|
||||
const { tabs, activeTabId, refreshKeys } = useTab();
|
||||
// 탭 순서 변경(드래그 리오더링) 시 DOM 재배치를 방지하기 위해
|
||||
// 탭이 추가된 순서(id 기준)로 고정 렌더링
|
||||
const stableOrderRef = useRef<TabItem[]>([]);
|
||||
|
||||
// F5 이후 활성 탭만 마운트하고, 비활성 탭은 클릭 시 마운트 (Lazy Mount)
|
||||
const mountedTabIdsRef = useRef<Set<string>>(
|
||||
new Set(activeTabId ? [activeTabId] : [])
|
||||
);
|
||||
|
||||
// 활성 탭을 마운트 목록에 추가
|
||||
if (activeTabId && !mountedTabIdsRef.current.has(activeTabId)) {
|
||||
mountedTabIdsRef.current.add(activeTabId);
|
||||
}
|
||||
|
||||
// 새 탭 추가 시 stableOrder에 append, 닫힌 탭은 제거
|
||||
const currentIds = new Set(tabs.map(t => t.id));
|
||||
const stableIds = new Set(stableOrderRef.current.map(t => t.id));
|
||||
|
||||
// 닫힌 탭 제거
|
||||
stableOrderRef.current = stableOrderRef.current.filter(t => currentIds.has(t.id));
|
||||
// 닫힌 탭을 마운트 목록에서도 제거
|
||||
for (const id of mountedTabIdsRef.current) {
|
||||
if (!currentIds.has(id)) mountedTabIdsRef.current.delete(id);
|
||||
}
|
||||
// 새로 추가된 탭 append
|
||||
for (const tab of tabs) {
|
||||
if (!stableIds.has(tab.id)) {
|
||||
stableOrderRef.current.push(tab);
|
||||
|
|
@ -74,20 +95,18 @@ export function TabContent() {
|
|||
return (
|
||||
<>
|
||||
{stableTabs.map((tab) => (
|
||||
<div
|
||||
<TabPanel
|
||||
key={`${tab.id}-${refreshKeys[tab.id] || 0}`}
|
||||
className="h-full w-full"
|
||||
style={{ display: tab.id === activeTabId ? "block" : "none" }}
|
||||
isActive={tab.id === activeTabId}
|
||||
isMounted={mountedTabIdsRef.current.has(tab.id)}
|
||||
>
|
||||
{mountedTabIdsRef.current.has(tab.id) ? (
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<ScreenViewPageEmbeddable
|
||||
screenId={tab.screenId}
|
||||
menuObjid={tab.menuObjid}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<ScreenViewPageEmbeddable
|
||||
screenId={tab.screenId}
|
||||
menuObjid={tab.menuObjid}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
|||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { useDialogPortalContainer } from "@/contexts/DialogPortalContext";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
|
|
@ -15,12 +16,19 @@ const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
|||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
>(({ className, style, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-[999] bg-black/80",
|
||||
"fixed z-[999] bg-black/80",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
top: "var(--dialog-overlay-top, 0px)",
|
||||
left: "var(--dialog-overlay-left, 0px)",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
|
|
@ -30,19 +38,28 @@ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
|||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
>(({ className, style, ...props }, ref) => {
|
||||
const portalContainer = useDialogPortalContainer();
|
||||
|
||||
return (
|
||||
<AlertDialogPortal container={portalContainer || undefined}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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,
|
||||
)}
|
||||
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}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
});
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDialogPortalContainer } from "@/contexts/DialogPortalContext";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
|
|
@ -17,40 +18,87 @@ const DialogClose = DialogPrimitive.Close;
|
|||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
>(({ className, style, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[999] bg-black/60",
|
||||
className,
|
||||
)}
|
||||
className={cn("fixed z-[999] bg-black/60", className)}
|
||||
style={{
|
||||
top: "var(--dialog-overlay-top, 0px)",
|
||||
left: "var(--dialog-overlay-left, 0px)",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
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<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
{...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>
|
||||
</DialogPortal>
|
||||
));
|
||||
>(({ className, children, onInteractOutside, onPointerDownOutside, style, ...props }, ref) => {
|
||||
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 />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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,
|
||||
)}
|
||||
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}
|
||||
>
|
||||
{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>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -131,6 +131,16 @@ export function TabProvider({ children }: { children: ReactNode }) {
|
|||
saveTabsToStorage(tabs, activeTabId);
|
||||
}, [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 tabId = generateTabId(tabData.screenId, tabData.menuObjid);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue