* {
+ flex: 1 1 0;
+ min-height: 0;
+ min-width: 0;
+}
+
+.paneBodyHidden {
+ display: none;
+}
+
+.collapseToggle {
+ flex-shrink: 0;
+ align-self: stretch;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ min-width: 28px;
+ margin: 0;
+ padding: 0;
+ border: none;
+ border-right: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
+ border-radius: 0;
+ background: var(--bg-secondary, #f5f5f5);
+ color: var(--text-secondary, #666);
+ cursor: pointer;
+ font-size: 12px;
+ line-height: 1;
+}
+
+.paneCollapsed .collapseToggle {
+ border-right: none;
+ border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.12));
+}
+
+.collapseToggle:hover {
+ background: var(--bg-hover, #ebebeb);
+}
+
+.divider {
+ flex-shrink: 0;
+ background: var(--border-color, rgba(0, 0, 0, 0.12));
+ z-index: 2;
+}
+
+.dividerHorizontal {
+ width: 4px;
+ cursor: col-resize;
+}
+
+.dividerVertical {
+ height: 4px;
+ cursor: row-resize;
+}
+
+.dividerDragging {
+ background: var(--primary-color, #2563eb);
+}
diff --git a/src/components/Layout/PanelLayout.tsx b/src/components/Layout/PanelLayout.tsx
new file mode 100644
index 0000000..a25c965
--- /dev/null
+++ b/src/components/Layout/PanelLayout.tsx
@@ -0,0 +1,298 @@
+// 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;
diff --git a/src/components/Layout/StackLayout.module.css b/src/components/Layout/StackLayout.module.css
index 3cf09bd..c261ebb 100644
--- a/src/components/Layout/StackLayout.module.css
+++ b/src/components/Layout/StackLayout.module.css
@@ -51,11 +51,25 @@
padding: 16px 20px;
}
+/* Scroll/form layouts: regions keep their natural height and the body scrolls,
+ instead of flex-shrinking children below their content (which clips data). */
+.bodyScroll > *,
+.bodyForm > * {
+ flex-shrink: 0;
+}
+
.bodyDashboard {
- overflow-y: auto;
+ flex: 0 0 auto;
+ overflow: visible;
display: flex;
flex-direction: column;
gap: 16px;
+ padding-bottom: 24px;
+}
+
+/* Dashboard root keeps its bounded height but scrolls its own content */
+.root[data-variant="dashboard"] {
+ overflow-y: auto;
}
/* ------------------------------------------------------------------ */
diff --git a/src/components/Layout/StackLayout.tsx b/src/components/Layout/StackLayout.tsx
index 34519de..545d950 100644
--- a/src/components/Layout/StackLayout.tsx
+++ b/src/components/Layout/StackLayout.tsx
@@ -1,8 +1,9 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
-import React, { type FC, type ReactNode, Children, isValidElement, cloneElement } from 'react';
+import React, { type FC, type ReactNode, Children, isValidElement, cloneElement, useRef } from 'react';
import type { StackLayoutProps, StackLayoutVariant } from './types';
import { useScrollMode } from '../../hooks/useScrollMode';
+import { useScrollRestoration } from '../../hooks/useScrollRestoration';
import styles from './StackLayout.module.css';
// ---------------------------------------------------------------------------
@@ -63,9 +64,12 @@ const _StackLayoutRoot: FC = ({
children,
}) => {
const scrollMode = useScrollMode();
+ const rootRef = useRef(null);
+ useScrollRestoration(rootRef);
return (
{
+ if (!React.isValidElement
(child)) return;
+ ids.push(child.props.id);
+ });
+ return ids;
+}
+
function _resolveActiveView(
searchParams: URLSearchParams,
viewParam: string,
entityParam: string | undefined,
- defaultView: ViewMode
-): ViewMode {
+ defaultView: ViewMode,
+ registeredViews: ViewMode[],
+): ViewResolution {
const rawView = searchParams.get(viewParam) as ViewMode | null;
const entityId = entityParam ? searchParams.get(entityParam) : null;
- let resolved: ViewMode = rawView ?? defaultView;
+ let sanitized = false;
- if (entityId && resolved === 'list') {
+ if (rawView && !VALID_VIEW_MODES.includes(rawView)) {
+ sanitized = true;
+ } else if (rawView && !registeredViews.includes(rawView)) {
+ sanitized = true;
+ }
+
+ let resolved: ViewMode = rawView && registeredViews.includes(rawView) ? rawView : defaultView;
+
+ if (entityId && resolved === 'list' && registeredViews.includes('detail')) {
resolved = 'detail';
}
- if (resolved === 'detail' && !entityId) {
+ if (resolved === 'detail') {
+ if (!registeredViews.includes('detail')) {
+ sanitized = true;
+ resolved = defaultView;
+ } else if (entityParam && !entityId) {
+ sanitized = true;
+ resolved = defaultView;
+ }
+ }
+
+ if (entityId && !registeredViews.includes('detail')) {
+ sanitized = true;
resolved = defaultView;
}
- return resolved;
+ return { activeView: resolved, sanitized };
}
function _findActiveChild(
children: React.ReactNode,
- activeView: ViewMode
+ activeView: ViewMode,
): ReactElement | null {
let match: ReactElement | null = null;
@@ -48,7 +86,7 @@ function _buildBackParams(
searchParams: URLSearchParams,
viewParam: string,
entityParam: string | undefined,
- defaultView: ViewMode
+ defaultView: ViewMode,
): URLSearchParams {
const next = new URLSearchParams(searchParams);
@@ -65,6 +103,23 @@ function _buildBackParams(
return next;
}
+function _buildSanitizedParams(
+ searchParams: URLSearchParams,
+ viewParam: string,
+ entityParam: string | undefined,
+ defaultView: ViewMode,
+): URLSearchParams {
+ const next = new URLSearchParams(searchParams);
+ next.delete(viewParam);
+ if (entityParam) {
+ next.delete(entityParam);
+ }
+ if (defaultView !== 'list') {
+ next.set(viewParam, defaultView);
+ }
+ return next;
+}
+
function View({ children }: ViewProps) {
return <>{children}>;
}
@@ -76,8 +131,33 @@ function ViewStack({
children,
}: ViewStackProps) {
const [searchParams, setSearchParams] = useSearchParams();
+ const { showWarning } = useToast();
+ const { t } = useLanguage();
+ const toastShownRef = useRef(false);
+
+ const registeredViews = useMemo(() => _collectChildIds(children), [children]);
+
+ const { activeView, sanitized } = useMemo(
+ () => _resolveActiveView(searchParams, viewParam, entityParam, defaultView, registeredViews),
+ [searchParams, viewParam, entityParam, defaultView, registeredViews],
+ );
+
+ useEffect(() => {
+ if (!sanitized || toastShownRef.current) return;
+ toastShownRef.current = true;
+ showWarning(t('Ungültige Ansicht'), t('Die angeforderte Ansicht ist nicht verfügbar.'));
+ setSearchParams(
+ _buildSanitizedParams(searchParams, viewParam, entityParam, defaultView),
+ { replace: true },
+ );
+ }, [sanitized, showWarning, t, setSearchParams, searchParams, viewParam, entityParam, defaultView]);
+
+ useEffect(() => {
+ if (!sanitized) {
+ toastShownRef.current = false;
+ }
+ }, [sanitized]);
- const activeView = _resolveActiveView(searchParams, viewParam, entityParam, defaultView);
const activeChild = _findActiveChild(children, activeView);
if (!activeChild) return null;
diff --git a/src/components/Layout/index.ts b/src/components/Layout/index.ts
index 47185e8..351cf40 100644
--- a/src/components/Layout/index.ts
+++ b/src/components/Layout/index.ts
@@ -25,3 +25,4 @@ export { default as ViewStack } from './ViewStack';
export { LayoutTabs } from './LayoutTabs';
export { Panel } from './Panel';
export { StackLayout } from './StackLayout';
+export { PanelLayout } from './PanelLayout';
diff --git a/src/components/Layout/types.ts b/src/components/Layout/types.ts
index fbc575f..92a7ff4 100644
--- a/src/components/Layout/types.ts
+++ b/src/components/Layout/types.ts
@@ -41,6 +41,14 @@ export interface LayoutTabsProps {
collapseKey?: string;
/** Start collapsed when no persisted state exists. */
defaultCollapsed?: boolean;
+ /**
+ * Fill the available height (default `true`): the active tab panel becomes a
+ * bounded flex column so a `table`/`editor` Panel inside it can scroll
+ * internally. Set `false` inside a `StackLayout variant="scroll"` page so the
+ * tab content keeps its natural height and the page scrolls instead of
+ * compressing the regions.
+ */
+ fill?: boolean;
}
// ---------------------------------------------------------------------------
@@ -80,6 +88,12 @@ export interface PanelProps {
defaultCollapsed?: boolean;
collapseKey?: string;
className?: string;
+ /**
+ * Fill the available height of the parent flex container and let the body
+ * own its scroll. Use when a `card` (or any non-table/editor) Panel is placed
+ * in a bounded region (split pane, StackLayout body) and should grow to fill.
+ */
+ fill?: boolean;
children: ReactNode;
}
@@ -104,3 +118,30 @@ export interface LayoutPersistenceAdapter {
load: (key: string) => T | null;
save: (key: string, value: T) => void;
}
+
+// ---------------------------------------------------------------------------
+// PanelLayout (split tree MVP)
+// ---------------------------------------------------------------------------
+
+export type PanelLayoutDirection = 'horizontal' | 'vertical';
+
+export interface PanelLayoutPaneConfig {
+ id: string;
+ content: ReactNode;
+ /** Default share in percent (all panes normalized to 100). */
+ defaultSize?: number;
+ minSize?: number;
+ maxSize?: number;
+ collapsible?: boolean;
+ collapseKey?: string;
+ defaultCollapsed?: boolean;
+ /** Collapsed strip size in px. Default: 40 */
+ collapsedSize?: number;
+}
+
+export interface PanelLayoutProps {
+ persistenceKey: string;
+ direction?: PanelLayoutDirection;
+ panes: PanelLayoutPaneConfig[];
+ className?: string;
+}
diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx
index b7083c8..cf9f1c8 100644
--- a/src/components/Navigation/MandateNavigation.tsx
+++ b/src/components/Navigation/MandateNavigation.tsx
@@ -39,6 +39,7 @@ import { usePrompt } from '../../hooks/usePrompt';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import { useLanguage } from '../../providers/language/LanguageContext';
+import { useSidebar } from '../../layouts/SidebarContext';
import styles from './MandateNavigation.module.css';
type NavTranslateFn = (key: string, params?: Record) => string;
@@ -210,6 +211,7 @@ const EmptyState: React.FC = () => {
export const MandateNavigation: React.FC = () => {
const { t } = useLanguage();
+ const { collapsed } = useSidebar();
const { blocks, loading, refresh } = useNavigation();
const { prompt, PromptDialog } = usePrompt();
const { showWarning } = useToast();
@@ -332,6 +334,7 @@ export const MandateNavigation: React.FC = () => {
) : (
diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css
index 4eaf04a..3563475 100644
--- a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css
+++ b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css
@@ -345,3 +345,82 @@
background: var(--primary-color, #2563eb);
color: white;
}
+
+/* ============================================ */
+/* COLLAPSED ICON RAIL */
+/* ============================================ */
+
+.treeNavigationCollapsed {
+ padding: 0.25rem 0.375rem;
+ gap: 0.25rem;
+ align-items: center;
+}
+
+.collapsedNavItem {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 8px;
+ color: var(--text-secondary, #64748b);
+ text-decoration: none;
+ transition: background 0.2s ease, color 0.2s ease;
+}
+
+.collapsedNavItem:hover {
+ background: var(--hover-bg, rgba(0, 0, 0, 0.04));
+ color: var(--text-primary, #1a1a1a);
+}
+
+.collapsedNavItemActive {
+ background: var(--primary-light, #e0e7ff);
+ color: var(--primary-color, #2563eb);
+}
+
+.collapsedNavIcon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1rem;
+}
+
+.collapsedNavLetter {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.75rem;
+ height: 1.75rem;
+ border-radius: 6px;
+ background: var(--surface-color, #f0f0f0);
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+
+.collapsedNavItemActive .collapsedNavLetter {
+ background: var(--primary-color, #2563eb);
+ color: var(--text-on-primary, #ffffff);
+}
+
+:global(.dark-theme) .collapsedNavItem {
+ color: var(--text-secondary-dark, #aaa);
+}
+
+:global(.dark-theme) .collapsedNavItem:hover {
+ background: var(--hover-bg-dark, rgba(255, 255, 255, 0.06));
+ color: var(--text-primary-dark, #fff);
+}
+
+:global(.dark-theme) .collapsedNavItemActive {
+ background: var(--primary-dark-bg, #1e3a5f);
+ color: var(--primary-light, #93c5fd);
+}
+
+:global(.dark-theme) .collapsedNavLetter {
+ background: var(--surface-dark, #2a2a2a);
+}
+
+:global(.dark-theme) .collapsedNavItemActive .collapsedNavLetter {
+ background: var(--primary-color, #2563eb);
+ color: var(--text-on-primary, #ffffff);
+}
diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx
index 2eff3a9..a2689ab 100644
--- a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx
+++ b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx
@@ -76,6 +76,8 @@ export interface TreeNavigationProps {
items: TreeItem[];
/** Whether to auto-expand nodes when their path is active */
autoExpandActive?: boolean;
+ /** Icon-only rail mode for collapsed sidebar */
+ collapsed?: boolean;
/** Callback when a node is clicked */
onNodeClick?: (node: TreeNodeItem) => void;
/** Maximum depth to render (0 = unlimited) */
@@ -122,6 +124,34 @@ function isTreeSeparator(item: TreeItem): item is TreeSeparatorItem {
return 'type' in item && item.type === 'separator';
}
+function _collectNavLinksFromNodes(nodes: TreeNodeItem[], result: TreeNodeItem[]): void {
+ for (const node of nodes) {
+ if (node.path) {
+ result.push(node);
+ }
+ if (node.children) {
+ _collectNavLinksFromNodes(node.children, result);
+ }
+ }
+}
+
+function _collectNavLinks(items: TreeItem[]): TreeNodeItem[] {
+ const result: TreeNodeItem[] = [];
+ for (const item of items) {
+ if (isTreeSeparator(item)) {
+ continue;
+ }
+ if (isTreeSection(item)) {
+ _collectNavLinksFromNodes(item.children, result);
+ continue;
+ }
+ if (isTreeNode(item)) {
+ _collectNavLinksFromNodes([item], result);
+ }
+ }
+ return result;
+}
+
// =============================================================================
// TREE NODE COMPONENT
// =============================================================================
@@ -344,6 +374,45 @@ const TreeSection: React.FC = ({
);
};
+// =============================================================================
+// COLLAPSED ICON RAIL
+// =============================================================================
+
+interface CollapsedNavItemProps {
+ node: TreeNodeItem;
+ currentPath: string;
+ onNodeClick?: (node: TreeNodeItem) => void;
+}
+
+const CollapsedNavItem: React.FC = ({ node, currentPath, onNodeClick }) => {
+ const isActive = node.path
+ ? currentPath === node.path || currentPath.startsWith(`${node.path}/`)
+ : false;
+ const letterFallback = node.label.trim().charAt(0).toLocaleUpperCase() || '?';
+
+ const handleClick = () => {
+ if (onNodeClick) {
+ onNodeClick(node);
+ }
+ };
+
+ return (
+
+ {node.icon ? (
+ {node.icon}
+ ) : (
+ {letterFallback}
+ )}
+
+ );
+};
+
// =============================================================================
// MAIN COMPONENT
// =============================================================================
@@ -351,6 +420,7 @@ const TreeSection: React.FC = ({
export const TreeNavigation: React.FC = ({
items,
autoExpandActive = true,
+ collapsed = false,
onNodeClick,
maxDepth = 0,
className = '',
@@ -358,6 +428,22 @@ export const TreeNavigation: React.FC = ({
const location = useLocation();
const currentPath = location.pathname;
+ if (collapsed) {
+ const navLinks = _collectNavLinks(items);
+ return (
+
+ );
+ }
+
return (