282 lines
8.4 KiB
TypeScript
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;
|