ui-nyla/src/components/Layout/PanelLayout.tsx
ValueOn AG d579df1c92
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
panel ui
2026-06-11 16:43:53 +02:00

298 lines
9.3 KiB
TypeScript

// 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<PanelLayoutProps> = ({
persistenceKey,
direction = 'horizontal',
panes,
className = '',
}) => {
const { t } = useLanguage();
const containerRef = useRef<HTMLDivElement>(null);
const [sizes, setSizes] = useState<number[]>(() => _loadSizes(persistenceKey, panes));
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
for (const pane of panes) {
initial[pane.id] = _loadCollapsed(pane.collapseKey, pane.defaultCollapsed ?? false);
}
return initial;
});
const [draggingIndex, setDraggingIndex] = useState<number | null>(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 (
<div
ref={containerRef}
className={`${styles.root} ${className}`}
data-direction={direction}
role="group"
>
{panes.map((pane, index) => (
<PaneSlot
key={pane.id}
pane={pane}
style={paneStyle(pane, index)}
collapsed={!!collapsedById[pane.id] && !!pane.collapsible}
onToggleCollapse={() => _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)}
/>
))}
</div>
);
};
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<PaneSlotProps> = ({
pane,
style,
collapsed,
onToggleCollapse,
collapseLabel,
expandLabel,
direction,
showDivider,
dividerClass,
onDividerMouseDown,
}) => {
const _collapseIcon = direction === 'horizontal'
? (collapsed ? <FaChevronRight aria-hidden /> : <FaChevronLeft aria-hidden />)
: (collapsed ? <FaChevronDown aria-hidden /> : <FaChevronUp aria-hidden />);
return (
<>
<div
className={`${styles.pane} ${collapsed ? styles.paneCollapsed : ''}`}
style={style}
data-pane-id={pane.id}
>
{pane.collapsible && (
<button
type="button"
className={styles.collapseToggle}
onClick={onToggleCollapse}
aria-expanded={!collapsed}
aria-label={collapsed ? expandLabel : collapseLabel}
>
{_collapseIcon}
</button>
)}
<div className={`${styles.paneBody} ${collapsed ? styles.paneBodyHidden : ''}`}>
{pane.content}
</div>
</div>
{showDivider && (
<div
className={dividerClass}
role="separator"
aria-orientation="vertical"
onMouseDown={onDividerMouseDown}
/>
)}
</>
);
};
export default PanelLayout;