diff --git a/frontend/components/common/ResponsiveSplitPanel.tsx b/frontend/components/common/ResponsiveSplitPanel.tsx index 860254a1..7b0f6031 100644 --- a/frontend/components/common/ResponsiveSplitPanel.tsx +++ b/frontend/components/common/ResponsiveSplitPanel.tsx @@ -6,36 +6,45 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; export interface ResponsiveSplitPanelProps { - /** 좌측 패널 콘텐츠 */ left: ReactNode; - /** 우측 패널 콘텐츠 */ right: ReactNode; - /** 좌측 패널 제목 (모바일 접기/펼치기 시 표시) */ leftTitle?: string; - /** 좌측 패널 기본 너비 (%, 기본: 25) */ leftWidth?: number; - /** 좌측 패널 최소 너비 (%, 기본: 10) */ minLeftWidth?: number; - /** 좌측 패널 최대 너비 (%, 기본: 50) */ maxLeftWidth?: number; - /** 리사이저 표시 여부 (기본: true) */ showResizer?: boolean; - /** 모바일에서 좌측 패널 기본 접힘 여부 (기본: true) */ collapsedOnMobile?: boolean; - /** 컨테이너 높이 (기본: "100%") */ height?: string; - /** 추가 className */ className?: string; - /** 좌측 패널 추가 className */ leftClassName?: string; - /** 우측 패널 추가 className */ rightClassName?: string; } -const MOBILE_BREAKPOINT = 1024; +type ViewMode = "desktop" | "tablet" | "mobile"; + +const DESKTOP_BREAKPOINT = 1280; +const TABLET_BREAKPOINT = 768; + +function getViewMode(width: number): ViewMode { + if (width >= DESKTOP_BREAKPOINT) return "desktop"; + if (width >= TABLET_BREAKPOINT) return "tablet"; + return "mobile"; +} + +function getAdaptiveLeftWidth( + baseWidth: number, + containerWidth: number, + minPx: number +): number { + const currentPx = (baseWidth / 100) * containerWidth; + if (currentPx < minPx) { + return Math.min((minPx / containerWidth) * 100, 50); + } + return baseWidth; +} export function ResponsiveSplitPanel({ left, @@ -51,28 +60,76 @@ export function ResponsiveSplitPanel({ leftClassName, rightClassName, }: ResponsiveSplitPanelProps) { - const [leftWidth, setLeftWidth] = useState(initialLeftWidth); - const [isMobileView, setIsMobileView] = useState(false); + const [userLeftWidth, setUserLeftWidth] = useState(initialLeftWidth); + const [viewMode, setViewMode] = useState("desktop"); const [leftCollapsed, setLeftCollapsed] = useState(false); + const [containerWidth, setContainerWidth] = useState(0); const containerRef = useRef(null); const isDraggingRef = useRef(false); + const prevViewModeRef = useRef("desktop"); - // 뷰포트 감지 + // 컨테이너 크기 + 뷰 모드 실시간 감지 useEffect(() => { - const checkMobile = () => { - const mobile = window.innerWidth < MOBILE_BREAKPOINT; - setIsMobileView(mobile); - if (mobile && collapsedOnMobile) { - setLeftCollapsed(true); - } - }; + const container = containerRef.current; + if (!container) return; - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const w = entry.contentRect.width; + setContainerWidth(w); + + const newMode = getViewMode(window.innerWidth); + setViewMode((prev) => { + if (prev !== newMode) { + if (newMode === "mobile" && collapsedOnMobile) { + setLeftCollapsed(true); + } else if (newMode === "tablet") { + setLeftCollapsed(true); + } else if (newMode === "desktop" && prev !== "desktop") { + setLeftCollapsed(false); + } + } + return newMode; + }); + }); + + observer.observe(container); + setContainerWidth(container.offsetWidth); + const initialMode = getViewMode(window.innerWidth); + setViewMode(initialMode); + if (initialMode === "mobile" && collapsedOnMobile) { + setLeftCollapsed(true); + } else if (initialMode === "tablet") { + setLeftCollapsed(true); + } + + return () => observer.disconnect(); }, [collapsedOnMobile]); - // 데스크톱 리사이저 + // window resize도 감지 (viewport 변화) + useEffect(() => { + const handleResize = () => { + const newMode = getViewMode(window.innerWidth); + setViewMode((prev) => { + if (prev !== newMode) { + if (newMode === "mobile" && collapsedOnMobile) { + setLeftCollapsed(true); + } else if (newMode === "tablet") { + setLeftCollapsed(true); + } else if (newMode === "desktop") { + setLeftCollapsed(false); + } + } + return newMode; + }); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [collapsedOnMobile]); + + // 리사이저 const handleMouseDown = useCallback(() => { isDraggingRef.current = true; document.body.style.cursor = "col-resize"; @@ -85,7 +142,7 @@ export function ResponsiveSplitPanel({ const rect = containerRef.current.getBoundingClientRect(); const pct = ((e.clientX - rect.left) / rect.width) * 100; if (pct >= minLeftWidth && pct <= maxLeftWidth) { - setLeftWidth(pct); + setUserLeftWidth(pct); } }, [minLeftWidth, maxLeftWidth] @@ -106,11 +163,17 @@ export function ResponsiveSplitPanel({ }; }, [handleMouseMove, handleMouseUp]); - // --- 모바일 레이아웃: 세로 스택 --- - if (isMobileView) { + // 좌측 패널 최소 px 보장 (200px 이하로 줄어들지 않게) + const MIN_LEFT_PX = 200; + const effectiveLeftWidth = + containerWidth > 0 + ? getAdaptiveLeftWidth(userLeftWidth, containerWidth, MIN_LEFT_PX) + : userLeftWidth; + + // --- 모바일: 세로 스택 --- + if (viewMode === "mobile") { return ( -
- {/* 좌측 패널 토글 헤더 */} +
- {/* 좌측 패널 (접기/펼치기) */} {!leftCollapsed && ( -
+
{left}
)} - {/* 우측 패널 (항상 표시) */}
{right}
@@ -147,39 +203,42 @@ export function ResponsiveSplitPanel({ ); } - // --- 데스크톱 레이아웃: 좌우 분할 --- + // --- 태블릿 + 데스크톱: 좌우 분할 --- return (
- {/* 좌측 패널 (접기 가능) */} + {/* 좌측 패널 */} {!leftCollapsed ? ( <>
{left}
- {/* 리사이저 */} - {showResizer && ( + {/* 리사이저 (데스크톱만) */} + {showResizer && viewMode === "desktop" && (
)} ) : ( -
+