"use client"; import * as React from "react"; import { cn } from "~/lib/utils"; import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet"; import { Button } from "~/components/ui/button"; import { PanelLeft, Settings2 } from "lucide-react"; type Edge = "left" | "right"; export interface PanelsContainerProps { left?: React.ReactNode; center: React.ReactNode; right?: React.ReactNode; /** * Draw dividers between panels (applied to center only to avoid double borders). * Defaults to true. */ showDividers?: boolean; /** Class applied to the root container */ className?: string; /** Class applied to each panel wrapper (left/center/right) */ panelClassName?: string; /** Class applied to each panel's internal scroll container */ contentClassName?: string; /** Accessible label for the overall layout */ "aria-label"?: string; /** Min/Max fractional widths for left and right panels (0..1), clamped during drag */ minLeftPct?: number; maxLeftPct?: number; minRightPct?: number; maxRightPct?: number; /** Keyboard resize step (fractional) per arrow press; Shift increases by 2x */ keyboardStepPct?: number; /** * Controlled collapse state */ leftCollapsed?: boolean; rightCollapsed?: boolean; onLeftCollapseChange?: (collapsed: boolean) => void; onRightCollapseChange?: (collapsed: boolean) => void; } /** * PanelsContainer * * Tailwind-first, grid-based panel layout with: * - Drag-resizable left/right panels (no persistence) * - Collapsible side panels * - Strict overflow containment (no page-level x-scroll) * - Internal y-scroll for each panel * - Optional visual dividers on the center panel only (prevents double borders) * * Implementation details: * - Uses CSS variables for column fractions and an explicit grid template: * [minmax(0,var(--col-left)) minmax(0,var(--col-center)) minmax(0,var(--col-right))] * - Resize handles are absolutely positioned over the grid at the left and right boundaries. * - Fractions are clamped with configurable min/max so panels remain usable at all sizes. */ const Panel: React.FC> = ({ className: panelCls, panelClassName, contentClassName, children, }) => (
{children}
); export function PanelsContainer({ left, center, right, showDividers = true, className, panelClassName, contentClassName, "aria-label": ariaLabel = "Designer panel layout", minLeftPct = 0.12, maxLeftPct = 0.33, minRightPct = 0.12, maxRightPct = 0.33, keyboardStepPct = 0.02, leftCollapsed = false, rightCollapsed = false, onLeftCollapseChange, onRightCollapseChange, }: PanelsContainerProps) { const hasLeft = Boolean(left); const hasRight = Boolean(right); const hasCenter = Boolean(center); // Fractions for side panels (center is derived as 1 - (left + right)) const [leftPct, setLeftPct] = React.useState(hasLeft ? 0.2 : 0); const [rightPct, setRightPct] = React.useState(hasRight ? 0.24 : 0); const rootRef = React.useRef(null); const dragRef = React.useRef<{ edge: Edge; startX: number; startLeft: number; startRight: number; containerWidth: number; } | null>(null); const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v)); const recompute = React.useCallback( (lp: number, rp: number) => { if (!hasCenter) return { l: 0, c: 0, r: 0 }; // Effective widths (0 if collapsed) const effectiveL = leftCollapsed ? 0 : lp; const effectiveR = rightCollapsed ? 0 : rp; // When logic runs, we must clamp the *underlying* percentages (lp, rp) // but return 0 for the CSS vars if collapsed. // Actually, if collapsed, we just want the CSS var to be 0. // But we maintain the state `leftPct` so it restores correctly. if (hasLeft && hasRight) { // Standard clamp (on the state values) const lState = clamp(lp, minLeftPct, maxLeftPct); const rState = clamp(rp, minRightPct, maxRightPct); // Effective output const l = leftCollapsed ? 0 : lState; const r = rightCollapsed ? 0 : rState; // Center takes remainder const c = 1 - (l + r); return { l, c, r }; } if (hasLeft && !hasRight) { const lState = clamp(lp, minLeftPct, maxLeftPct); const l = leftCollapsed ? 0 : lState; const c = 1 - l; return { l, c, r: 0 }; } if (!hasLeft && hasRight) { const rState = clamp(rp, minRightPct, maxRightPct); const r = rightCollapsed ? 0 : rState; const c = 1 - r; return { l: 0, c, r }; } // Center only return { l: 0, c: 1, r: 0 }; }, [ hasCenter, hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct, leftCollapsed, rightCollapsed ], ); const { l, c, r } = recompute(leftPct, rightPct); // Attach/detach global pointer handlers safely const onPointerMove = React.useCallback( (e: PointerEvent) => { const d = dragRef.current; if (!d || d.containerWidth <= 0) return; const deltaPx = e.clientX - d.startX; const deltaPct = deltaPx / d.containerWidth; if (d.edge === "left" && hasLeft && !leftCollapsed) { const nextLeft = clamp(d.startLeft + deltaPct, minLeftPct, maxLeftPct); setLeftPct(nextLeft); } else if (d.edge === "right" && hasRight && !rightCollapsed) { // Dragging the right edge moves leftwards as delta increases const nextRight = clamp( d.startRight - deltaPct, minRightPct, maxRightPct, ); setRightPct(nextRight); } }, [hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct, leftCollapsed, rightCollapsed], ); const endDrag = React.useCallback(() => { dragRef.current = null; window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", endDrag); }, [onPointerMove]); const startDrag = (edge: Edge) => (e: React.PointerEvent) => { if (!rootRef.current) return; e.preventDefault(); const rect = rootRef.current.getBoundingClientRect(); dragRef.current = { edge, startX: e.clientX, startLeft: leftPct, startRight: rightPct, containerWidth: rect.width, }; window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", endDrag); }; React.useEffect(() => { return () => { // Cleanup if unmounted mid-drag window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", endDrag); }; }, [onPointerMove, endDrag]); // Keyboard resize for handles const onKeyResize = (edge: Edge) => (e: React.KeyboardEvent) => { if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; e.preventDefault(); const step = (e.shiftKey ? 2 : 1) * keyboardStepPct; if (edge === "left" && hasLeft && !leftCollapsed) { const next = clamp( leftPct + (e.key === "ArrowRight" ? step : -step), minLeftPct, maxLeftPct, ); setLeftPct(next); } else if (edge === "right" && hasRight && !rightCollapsed) { const next = clamp( rightPct + (e.key === "ArrowLeft" ? step : -step), minRightPct, maxRightPct, ); setRightPct(next); } }; // CSS variables for the grid fractions // We use FR units instead of % to let the browser handle exact pixel fitting without rounding errors causing overflow const styleVars: React.CSSProperties & Record = hasCenter ? { "--col-left": `${hasLeft ? l : 0}fr`, "--col-center": `${c}fr`, "--col-right": `${hasRight ? r : 0}fr`, } : {}; // Explicit grid template depending on which side panels exist const gridAreas = hasLeft && hasRight ? '"left center right"' : hasLeft && !hasRight ? '"left center"' : !hasLeft && hasRight ? '"center right"' : '"center"'; const gridCols = hasLeft && hasRight ? "[grid-template-columns:var(--col-left)_var(--col-center)_var(--col-right)]" : hasLeft && !hasRight ? "[grid-template-columns:var(--col-left)_var(--col-center)]" : !hasLeft && hasRight ? "[grid-template-columns:var(--col-center)_var(--col-right)]" : "[grid-template-columns:1fr]"; // Dividers on the center panel only (prevents double borders if children have their own borders) const centerDividers = showDividers && hasCenter ? cn({ "border-l": hasLeft, "border-r": hasRight, }) : undefined; return ( <> {/* Mobile Layout (Flex + Sheets) */}
{/* Mobile Header/Toolbar for access to panels */}
{hasLeft && (
{left}
)} Designer
{hasRight && (
{right}
)}
{/* Main Content (Center) */}
{center}
{/* Desktop Layout (Grid) */} ); } export default PanelsContainer;