Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
298 lines
9.3 KiB
TypeScript
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;
|