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

View File

@ -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>
</>
);
};

View File

@ -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">

View File

@ -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>
))}
</>
);

View File

@ -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>) => (

View File

@ -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>) => (

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);
}, [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);