'use client'
import React, { useState, useRef, useEffect, useCallback, ReactNode } from 'react'
import { IoChevronBackOutline, IoChevronForwardOutline } from 'react-icons/io5'
interface ResizablePanelProps {
children: ReactNode
side: 'left' | 'right'
minWidth?: number
maxWidth?: number
defaultWidth?: number
defaultCollapsed?: boolean
collapsible?: boolean
persistKey?: string // For localStorage persistence
className?: string
onWidthChange?: (width: number) => void
onCollapseChange?: (collapsed: boolean) => void
}
export default function ResizablePanel({
children,
side,
minWidth = 200,
maxWidth = 600,
defaultWidth = 280,
defaultCollapsed = false,
collapsible = true,
persistKey,
className = '',
onWidthChange,
onCollapseChange
}: ResizablePanelProps) {
const panelRef = useRef<HTMLDivElement>(null)
const isDragging = useRef(false)
const startX = useRef(0)
const startWidth = useRef(0)
// Load persisted state
const loadPersistedState = useCallback(() => {
if (!persistKey || typeof window === 'undefined') {
return { width: defaultWidth, collapsed: defaultCollapsed }
}
try {
const stored = localStorage.getItem(`panel_${persistKey}`)
if (stored) {
const parsed = JSON.parse(stored)
return {
width: Math.max(minWidth, Math.min(maxWidth, parsed.width || defaultWidth)),
collapsed: parsed.collapsed ?? defaultCollapsed
}
}
} catch (error) {
console.warn('Failed to load panel state:', error)
}
return { width: defaultWidth, collapsed: defaultCollapsed }
}, [persistKey, defaultWidth, defaultCollapsed, minWidth, maxWidth])
const [panelState, setPanelState] = useState(loadPersistedState)
// Save state to localStorage
const saveState = useCallback((width: number, collapsed: boolean) => {
if (!persistKey || typeof window === 'undefined') return
try {
localStorage.setItem(`panel_${persistKey}`, JSON.stringify({ width, collapsed }))
} catch (error) {
console.warn('Failed to save panel state:', error)
}
}, [persistKey])
// Handle mouse move for resizing
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isDragging.current) return
const deltaX = side === 'left' ? e.clientX - startX.current : startX.current - e.clientX
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth.current + deltaX))
setPanelState(prev => {
const updated = { ...prev, width: newWidth }
saveState(updated.width, updated.collapsed)
onWidthChange?.(newWidth)
return updated
})
}, [side, minWidth, maxWidth, saveState, onWidthChange])
// Handle mouse up to stop resizing
const handleMouseUp = useCallback(() => {
isDragging.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}, [])
// Set up global mouse events
useEffect(() => {
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [handleMouseMove, handleMouseUp])
// Handle mouse down on resize handle
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault()
isDragging.current = true
startX.current = e.clientX
startWidth.current = panelState.width
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [panelState.width])
// Toggle collapsed state
const toggleCollapsed = useCallback(() => {
setPanelState(prev => {
const updated = { ...prev, collapsed: !prev.collapsed }
saveState(updated.width, updated.collapsed)
onCollapseChange?.(updated.collapsed)
return updated
})
}, [saveState, onCollapseChange])
// Determine panel styles
const panelWidth = panelState.collapsed ? 0 : panelState.width
const isLeft = side === 'left'
const currentWidth = panelState.collapsed ? 24 : panelState.width
return (
<div
ref={panelRef}
className={`relative flex-shrink-0 ${className}`}
style={{
width: `${currentWidth}px`,
transition: isDragging.current ? 'none' : 'width 300ms ease-in-out'
}}
>
{/* Panel Content */}
<div
className={`h-full absolute inset-0 transition-opacity duration-300 ease-in-out ${
panelState.collapsed ? 'opacity-0 pointer-events-none' : 'opacity-100'
}`}
>
{children}
</div>
{/* Resize Handle */}
{!panelState.collapsed && (
<div
className={`absolute top-0 bottom-0 w-1 bg-transparent hover:bg-slate-600/50 cursor-col-resize transition-colors duration-200 z-10 ${
isLeft ? 'right-0' : 'left-0'
}`}
onMouseDown={handleMouseDown}
>
{/* Visual indicator for resize handle */}
<div className={`absolute top-1/2 transform -translate-y-1/2 w-1 h-8 bg-slate-500/30 rounded-full transition-opacity duration-200 hover:opacity-100 opacity-0 ${
isLeft ? 'right-0' : 'left-0'
}`} />
</div>
)}
{/* Collapse Button (when panel is open) */}
{collapsible && !panelState.collapsed && (
<button
onClick={toggleCollapsed}
className={`absolute top-1/2 transform -translate-y-1/2 w-6 h-6 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-600/50 rounded-full flex items-center justify-center text-slate-300 hover:text-white transition-all duration-200 z-20 ${
isLeft ? 'right-1' : 'left-1'
}`}
title="Collapse panel"
>
{isLeft ? <IoChevronBackOutline className="w-3 h-3" /> : <IoChevronForwardOutline className="w-3 h-3" />}
</button>
)}
{/* Expand Button (when panel is collapsed) */}
{collapsible && panelState.collapsed && (
<button
onClick={toggleCollapsed}
className={`absolute top-1/2 transform -translate-y-1/2 w-6 h-6 bg-slate-800/90 hover:bg-slate-700/90 border border-slate-600/50 rounded-full flex items-center justify-center text-slate-300 hover:text-white transition-all duration-200 z-30 shadow-lg ${
isLeft ? 'left-0' : 'right-0'
}`}
title="Expand panel"
>
{isLeft ? <IoChevronForwardOutline className="w-3 h-3" /> : <IoChevronBackOutline className="w-3 h-3" />}
</button>
)}
</div>
)
}