// Copyright (c) 2026 PowerOn AG // All rights reserved. /** * PanelLayout — config-driven horizontal/vertical split layout (MVP). * * Supports 2+ resizable panes with optional collapse and localStorage persistence. * Nested split trees can be composed by nesting PanelLayout instances in pane content. */ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type FC, type MouseEvent as ReactMouseEvent, } from 'react'; import { FaChevronLeft, FaChevronRight, FaChevronUp, FaChevronDown } from 'react-icons/fa'; import type { PanelLayoutPaneConfig, PanelLayoutProps } from './types'; import { useVisibilityRemeasure } from '../../hooks/useVisibilityRemeasure'; import { useLanguage } from '../../providers/language/LanguageContext'; import styles from './PanelLayout.module.css'; const STORAGE_PREFIX = 'po_panel_layout:'; function _loadCollapsed(key: string | undefined, fallback: boolean): boolean { if (!key) return fallback; try { const stored = localStorage.getItem(`panel-collapse:${key}`); if (stored !== null) return stored === '1'; } catch { /* noop */ } return fallback; } function _saveCollapsed(key: string | undefined, value: boolean): void { if (!key) return; try { localStorage.setItem(`panel-collapse:${key}`, value ? '1' : '0'); } catch { /* noop */ } } function _normalizeSizes(sizes: number[]): number[] { const total = sizes.reduce((sum, s) => sum + s, 0); if (total <= 0) return sizes.map(() => 100 / sizes.length); return sizes.map((s) => (s / total) * 100); } function _loadSizes(persistenceKey: string, panes: PanelLayoutPaneConfig[]): number[] { const defaults = panes.map((p) => p.defaultSize ?? 100 / panes.length); try { const raw = localStorage.getItem(`${STORAGE_PREFIX}${persistenceKey}`); if (!raw) return _normalizeSizes(defaults); const parsed = JSON.parse(raw) as number[]; if (!Array.isArray(parsed) || parsed.length !== panes.length) { return _normalizeSizes(defaults); } return _normalizeSizes(parsed); } catch { return _normalizeSizes(defaults); } } function _saveSizes(persistenceKey: string, sizes: number[]): void { try { localStorage.setItem(`${STORAGE_PREFIX}${persistenceKey}`, JSON.stringify(sizes)); } catch { /* noop */ } } function _clampPaneSize( pane: PanelLayoutPaneConfig, size: number, ): number { const min = pane.minSize ?? 10; const max = pane.maxSize ?? 80; return Math.max(min, Math.min(max, size)); } export const PanelLayout: FC = ({ persistenceKey, direction = 'horizontal', panes, className = '', }) => { const { t } = useLanguage(); const containerRef = useRef(null); const [sizes, setSizes] = useState(() => _loadSizes(persistenceKey, panes)); const [collapsedById, setCollapsedById] = useState>(() => { const initial: Record = {}; for (const pane of panes) { initial[pane.id] = _loadCollapsed(pane.collapseKey, pane.defaultCollapsed ?? false); } return initial; }); const [draggingIndex, setDraggingIndex] = useState(null); const dragRef = useRef<{ index: number; startPos: number; startSizes: number[]; containerSize: number } | null>(null); useEffect(() => { setSizes(_loadSizes(persistenceKey, panes)); }, [persistenceKey, panes.length]); useEffect(() => { if (draggingIndex === null) { _saveSizes(persistenceKey, sizes); } }, [sizes, persistenceKey, draggingIndex]); const _toggleCollapsed = useCallback((pane: PanelLayoutPaneConfig) => { setCollapsedById((prev) => { const next = !prev[pane.id]; _saveCollapsed(pane.collapseKey, next); return { ...prev, [pane.id]: next }; }); }, []); const _handleDividerMouseDown = useCallback((index: number, e: ReactMouseEvent) => { e.preventDefault(); const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const containerSize = direction === 'horizontal' ? rect.width : rect.height; const startPos = direction === 'horizontal' ? e.clientX : e.clientY; dragRef.current = { index, startPos, startSizes: [...sizes], containerSize }; setDraggingIndex(index); }, [direction, sizes]); useEffect(() => { if (draggingIndex === null) return; const _onMouseMove = (e: MouseEvent) => { const drag = dragRef.current; if (!drag) return; const currentPos = direction === 'horizontal' ? e.clientX : e.clientY; const deltaPercent = ((currentPos - drag.startPos) / drag.containerSize) * 100; const next = [...drag.startSizes]; const leftPane = panes[drag.index]; const rightPane = panes[drag.index + 1]; let leftSize = next[drag.index] + deltaPercent; let rightSize = next[drag.index + 1] - deltaPercent; leftSize = _clampPaneSize(leftPane, leftSize); rightSize = _clampPaneSize(rightPane, rightSize); const pairTotal = drag.startSizes[drag.index] + drag.startSizes[drag.index + 1]; const adjustedTotal = leftSize + rightSize; if (Math.abs(adjustedTotal - pairTotal) > 0.01) { const scale = pairTotal / adjustedTotal; leftSize *= scale; rightSize *= scale; } next[drag.index] = leftSize; next[drag.index + 1] = rightSize; setSizes(_normalizeSizes(next)); }; const _onMouseUp = () => { dragRef.current = null; setDraggingIndex(null); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.addEventListener('mousemove', _onMouseMove); document.addEventListener('mouseup', _onMouseUp); document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize'; document.body.style.userSelect = 'none'; return () => { document.removeEventListener('mousemove', _onMouseMove); document.removeEventListener('mouseup', _onMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; }, [draggingIndex, direction, panes]); const _remeasure = useCallback(() => { containerRef.current?.dispatchEvent(new Event('panel-layout-remeasure')); }, []); useVisibilityRemeasure(containerRef, _remeasure); const paneStyle = useCallback((pane: PanelLayoutPaneConfig, index: number): CSSProperties => { const collapsed = collapsedById[pane.id] && pane.collapsible; if (collapsed) { const collapsedPx = pane.collapsedSize ?? 40; return direction === 'horizontal' ? { flex: `0 0 ${collapsedPx}px`, width: collapsedPx } : { flex: `0 0 ${collapsedPx}px`, height: collapsedPx }; } const percent = sizes[index] ?? 100 / panes.length; return { flex: `${percent} 1 0`, minWidth: 0, minHeight: 0 }; }, [collapsedById, direction, panes.length, sizes]); const dividerClass = useMemo( () => `${styles.divider} ${direction === 'horizontal' ? styles.dividerHorizontal : styles.dividerVertical}`, [direction], ); if (panes.length < 2) { throw new Error('PanelLayout requires at least 2 panes'); } return (
{panes.map((pane, index) => ( _toggleCollapsed(pane)} collapseLabel={t('Panel einklappen')} expandLabel={t('Panel ausklappen')} direction={direction} showDivider={index < panes.length - 1} dividerClass={`${dividerClass} ${draggingIndex === index ? styles.dividerDragging : ''}`} onDividerMouseDown={(e) => _handleDividerMouseDown(index, e)} /> ))}
); }; interface PaneSlotProps { pane: PanelLayoutPaneConfig; style: CSSProperties; collapsed: boolean; onToggleCollapse: () => void; collapseLabel: string; expandLabel: string; direction: 'horizontal' | 'vertical'; showDivider: boolean; dividerClass: string; onDividerMouseDown: (e: ReactMouseEvent) => void; } const PaneSlot: FC = ({ pane, style, collapsed, onToggleCollapse, collapseLabel, expandLabel, direction, showDivider, dividerClass, onDividerMouseDown, }) => { const _collapseIcon = direction === 'horizontal' ? (collapsed ? : ) : (collapsed ? : ); return ( <>
{pane.collapsible && ( )}
{pane.content}
{showDivider && (
)} ); }; export default PanelLayout;