ERP-node/frontend/components/common/ResponsiveSplitPanel.tsx

282 lines
8.4 KiB
TypeScript

"use client";
import React, { useState, useRef, useCallback, useEffect, ReactNode } from "react";
import { GripVertical, ChevronDown, ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface ResponsiveSplitPanelProps {
left: ReactNode;
right: ReactNode;
leftTitle?: string;
leftWidth?: number;
minLeftWidth?: number;
maxLeftWidth?: number;
showResizer?: boolean;
collapsedOnMobile?: boolean;
height?: string;
className?: string;
leftClassName?: string;
rightClassName?: string;
}
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,
right,
leftTitle = "목록",
leftWidth: initialLeftWidth = 25,
minLeftWidth = 10,
maxLeftWidth = 50,
showResizer = true,
collapsedOnMobile = true,
height = "100%",
className,
leftClassName,
rightClassName,
}: ResponsiveSplitPanelProps) {
const [userLeftWidth, setUserLeftWidth] = useState(initialLeftWidth);
const [viewMode, setViewMode] = useState<ViewMode>("desktop");
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [containerWidth, setContainerWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const prevViewModeRef = useRef<ViewMode>("desktop");
// 컨테이너 크기 + 뷰 모드 실시간 감지
useEffect(() => {
const container = containerRef.current;
if (!container) return;
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";
document.body.style.userSelect = "none";
}, []);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDraggingRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const pct = ((e.clientX - rect.left) / rect.width) * 100;
if (pct >= minLeftWidth && pct <= maxLeftWidth) {
setUserLeftWidth(pct);
}
},
[minLeftWidth, maxLeftWidth]
);
const handleMouseUp = useCallback(() => {
isDraggingRef.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
}, []);
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [handleMouseMove, handleMouseUp]);
// 좌측 패널 최소 px 보장 (200px 이하로 줄어들지 않게)
const MIN_LEFT_PX = 200;
const effectiveLeftWidth =
containerWidth > 0
? getAdaptiveLeftWidth(userLeftWidth, containerWidth, MIN_LEFT_PX)
: userLeftWidth;
// --- 모바일: 세로 스택 ---
if (viewMode === "mobile") {
return (
<div ref={containerRef} className={cn("flex flex-col gap-0", className)} style={{ height }}>
<button
onClick={() => setLeftCollapsed(!leftCollapsed)}
className={cn(
"flex w-full items-center justify-between border-b px-3 py-2.5",
"bg-muted/50 hover:bg-muted transition-colors",
"text-sm font-medium"
)}
>
<span>{leftTitle}</span>
{leftCollapsed ? (
<ChevronRight className="text-muted-foreground h-4 w-4" />
) : (
<ChevronDown className="text-muted-foreground h-4 w-4" />
)}
</button>
{!leftCollapsed && (
<div className={cn("max-h-[40vh] overflow-y-auto border-b", leftClassName)}>
{left}
</div>
)}
<div className={cn("min-h-0 flex-1 overflow-y-auto", rightClassName)}>
{right}
</div>
</div>
);
}
// --- 태블릿 + 데스크톱: 좌우 분할 ---
return (
<div
ref={containerRef}
className={cn("flex gap-0 overflow-hidden", className)}
style={{ height }}
>
{/* 좌측 패널 */}
{!leftCollapsed ? (
<>
<div
style={{ width: `${effectiveLeftWidth}%` }}
className={cn(
"flex h-full flex-col overflow-hidden border-r pr-1 transition-[width] duration-200",
leftClassName
)}
>
<div className="flex-1 overflow-y-auto">{left}</div>
</div>
{/* 리사이저 (데스크톱만) */}
{showResizer && viewMode === "desktop" && (
<div
onMouseDown={handleMouseDown}
className="group hover:bg-accent/50 relative flex h-full w-2 shrink-0 cursor-col-resize items-center justify-center transition-colors"
>
<GripVertical className="text-muted-foreground group-hover:text-foreground h-4 w-4 transition-colors" />
</div>
)}
</>
) : (
<div className="flex h-full w-10 shrink-0 flex-col items-center border-r pt-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setLeftCollapsed(false)}
title={`${leftTitle} 열기`}
>
<PanelLeftOpen className="h-4 w-4" />
</Button>
</div>
)}
{/* 우측 패널 */}
<div
style={{
width: leftCollapsed
? "calc(100% - 40px)"
: `${100 - effectiveLeftWidth}%`,
}}
className={cn(
"flex h-full flex-col overflow-hidden pl-2 transition-[width] duration-200",
rightClassName
)}
>
{!leftCollapsed && (
<div className="mb-1 flex justify-start">
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-6 w-6"
onClick={() => setLeftCollapsed(true)}
title={`${leftTitle} 접기`}
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto">{right}</div>
</div>
</div>
);
}
export default ResponsiveSplitPanel;