219 lines
6.7 KiB
TypeScript
219 lines
6.7 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;
|
||
|
|
/** 좌측 패널 기본 너비 (%, 기본: 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;
|
||
|
|
|
||
|
|
export function ResponsiveSplitPanel({
|
||
|
|
left,
|
||
|
|
right,
|
||
|
|
leftTitle = "목록",
|
||
|
|
leftWidth: initialLeftWidth = 25,
|
||
|
|
minLeftWidth = 10,
|
||
|
|
maxLeftWidth = 50,
|
||
|
|
showResizer = true,
|
||
|
|
collapsedOnMobile = true,
|
||
|
|
height = "100%",
|
||
|
|
className,
|
||
|
|
leftClassName,
|
||
|
|
rightClassName,
|
||
|
|
}: ResponsiveSplitPanelProps) {
|
||
|
|
const [leftWidth, setLeftWidth] = useState(initialLeftWidth);
|
||
|
|
const [isMobileView, setIsMobileView] = useState(false);
|
||
|
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
|
const isDraggingRef = useRef(false);
|
||
|
|
|
||
|
|
// 뷰포트 감지
|
||
|
|
useEffect(() => {
|
||
|
|
const checkMobile = () => {
|
||
|
|
const mobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||
|
|
setIsMobileView(mobile);
|
||
|
|
if (mobile && collapsedOnMobile) {
|
||
|
|
setLeftCollapsed(true);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
checkMobile();
|
||
|
|
window.addEventListener("resize", checkMobile);
|
||
|
|
return () => window.removeEventListener("resize", checkMobile);
|
||
|
|
}, [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) {
|
||
|
|
setLeftWidth(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]);
|
||
|
|
|
||
|
|
// --- 모바일 레이아웃: 세로 스택 ---
|
||
|
|
if (isMobileView) {
|
||
|
|
return (
|
||
|
|
<div 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: `${leftWidth}%` }}
|
||
|
|
className={cn("flex h-full flex-col overflow-hidden pr-3", leftClassName)}
|
||
|
|
>
|
||
|
|
<div className="flex-1 overflow-y-auto">{left}</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 리사이저 */}
|
||
|
|
{showResizer && (
|
||
|
|
<div
|
||
|
|
onMouseDown={handleMouseDown}
|
||
|
|
className="group hover:bg-accent/50 relative flex h-full w-3 shrink-0 cursor-col-resize items-center justify-center border-r transition-colors"
|
||
|
|
>
|
||
|
|
<GripVertical className="text-muted-foreground group-hover:text-foreground h-4 w-4 transition-colors" />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
<div className="flex h-full w-8 shrink-0 items-start justify-center border-r pt-2">
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-6 w-6"
|
||
|
|
onClick={() => setLeftCollapsed(false)}
|
||
|
|
title={`${leftTitle} 열기`}
|
||
|
|
>
|
||
|
|
<PanelLeftOpen className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 우측 패널 */}
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
width: leftCollapsed ? "calc(100% - 32px)" : `${100 - leftWidth - 1}%`,
|
||
|
|
}}
|
||
|
|
className={cn("flex h-full flex-col overflow-hidden pl-3", 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;
|