/** * TreeNavigation * * A flexible, recursive tree navigation component that supports: * - Dynamic sublevels of any depth * - Expandable/collapsible nodes * - Auto-expand based on active path * - Customizable icons and badges * - Section headers * - NavLink integration with React Router */ import React, { useState, useEffect, useRef, useCallback, ReactNode } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; import styles from './TreeNavigation.module.css'; // ============================================================================= // TYPES // ============================================================================= export interface TreeNodeItem { /** Unique identifier for this node */ id: string; /** Display label */ label: string; /** Icon to display (React component or element) */ icon?: ReactNode; /** Badge content (e.g., count, role) */ badge?: string | number; /** Optional badge style variant */ badgeVariant?: 'default' | 'primary' | 'success' | 'warning'; /** Path for navigation (if this is a link) */ path?: string; /** Child nodes */ children?: TreeNodeItem[]; /** Whether this node is expanded by default */ defaultExpanded?: boolean; /** Whether this node can be expanded/collapsed (default: true if has children) */ expandable?: boolean; /** Custom onClick handler (overrides navigation) */ onClick?: () => void; /** Whether this node is disabled */ disabled?: boolean; /** Additional CSS class */ className?: string; /** Indent level (auto-calculated) */ level?: number; /** Data attribute for testing/identification */ dataId?: string; /** Inline action element rendered at the end of the row (e.g. rename icon) */ actions?: ReactNode; } export interface TreeSectionItem { /** Section type */ type: 'section'; /** Section title */ title: string; /** Child nodes in this section */ children: TreeNodeItem[]; /** Whether this section is initially visible */ visible?: boolean; } export interface TreeSeparatorItem { /** Separator type */ type: 'separator'; } export type TreeItem = TreeNodeItem | TreeSectionItem | TreeSeparatorItem; export interface TreeNavigationProps { /** Array of tree items to render */ items: TreeItem[]; /** Whether to auto-expand nodes when their path is active */ autoExpandActive?: boolean; /** Callback when a node is clicked */ onNodeClick?: (node: TreeNodeItem) => void; /** Maximum depth to render (0 = unlimited) */ maxDepth?: number; /** Additional CSS class for the container */ className?: string; } // ============================================================================= // HELPER FUNCTIONS // ============================================================================= /** * Check if a node or any of its descendants has the active path */ function hasActivePath(node: TreeNodeItem, currentPath: string): boolean { if (node.path && currentPath.startsWith(node.path)) { return true; } if (node.children) { return node.children.some(child => hasActivePath(child, currentPath)); } return false; } /** * Type guard to check if item is a TreeNodeItem */ function isTreeNode(item: TreeItem): item is TreeNodeItem { return !('type' in item); } /** * Type guard to check if item is a TreeSectionItem */ function isTreeSection(item: TreeItem): item is TreeSectionItem { return 'type' in item && item.type === 'section'; } /** * Type guard to check if item is a TreeSeparatorItem */ function isTreeSeparator(item: TreeItem): item is TreeSeparatorItem { return 'type' in item && item.type === 'separator'; } // ============================================================================= // TREE NODE COMPONENT // ============================================================================= interface TreeNodeProps { node: TreeNodeItem; level: number; /** True when the parent row shows an icon — used to align label-only children with the parent's title text. */ parentHasIcon?: boolean; autoExpandActive: boolean; currentPath: string; onNodeClick?: (node: TreeNodeItem) => void; maxDepth: number; } const TreeNode: React.FC = ({ node, level, parentHasIcon = false, autoExpandActive, currentPath, onNodeClick, maxDepth, }) => { const hasChildren = node.children && node.children.length > 0; const isExpandable = node.expandable !== false && hasChildren; const shouldAutoExpand = autoExpandActive && hasActivePath(node, currentPath); const [isExpanded, setIsExpanded] = useState( node.defaultExpanded ?? shouldAutoExpand ?? false ); const containerRef = useRef(null); // Auto-expand when path becomes active useEffect(() => { if (autoExpandActive && hasActivePath(node, currentPath) && !isExpanded) { setIsExpanded(true); } }, [currentPath, autoExpandActive, node]); const _scrollAfterExpand = useCallback(() => { const el = containerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const viewportMid = window.innerHeight / 2; if (rect.top > viewportMid) { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } }, []); // Check if this node is active (exact match or ancestor of active path) const isActive = node.path ? currentPath === node.path || currentPath.startsWith(node.path + '/') : false; // Differentiate: leaf active (strong highlight) vs group active (subtle text only) const isLeafActive = isActive && !hasChildren; const isGroupActive = isActive && !!hasChildren; // Handle click const handleClick = (e: React.MouseEvent) => { if (node.disabled) { e.preventDefault(); return; } if (node.onClick) { e.preventDefault(); node.onClick(); return; } if (isExpandable && !node.path) { const willExpand = !isExpanded; setIsExpanded(willExpand); if (willExpand) setTimeout(_scrollAfterExpand, 50); } else if (isExpandable && node.path) { if (!isExpanded) { setIsExpanded(true); setTimeout(_scrollAfterExpand, 50); } } if (onNodeClick) { onNodeClick(node); } }; // Handle toggle click separately (expand/collapse) const handleToggleClick = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); const willExpand = !isExpanded; setIsExpanded(willExpand); if (willExpand) setTimeout(_scrollAfterExpand, 50); }; // Render the node content (actions are rendered outside to avoid button-in-button nesting) const nodeContent = ( <> {isExpandable && ( )} {!isExpandable && hasChildren === false && ( )} {node.icon && {node.icon}} {node.label} {node.badge !== undefined && ( {node.badge} )} ); // Unterknoten ohne Icon unter einem Knoten mit Icon: Text mit Eltern-Titel ausrichten (nicht mit Icon-Spalte) const alignLabelWithParentTitle = parentHasIcon && !node.icon; const nodeClasses = `${styles.treeNode} ${isLeafActive ? styles.active : ''} ${isGroupActive ? styles.activeGroup : ''} ${node.disabled ? styles.disabled : ''} ${alignLabelWithParentTitle ? styles.treeNodeAlignWithParentTitle : ''} ${node.className || ''}`; const nodeElement = node.path ? ( {nodeContent} ) : ( ); // Check max depth const canRenderChildren = maxDepth === 0 || level < maxDepth; return (
{nodeElement} {node.actions && ( e.stopPropagation()}> {node.actions} )} {isExpanded && hasChildren && canRenderChildren && (
{node.children!.map((child, index) => ( ))}
)}
); }; // ============================================================================= // TREE SECTION COMPONENT // ============================================================================= interface TreeSectionProps { section: TreeSectionItem; autoExpandActive: boolean; currentPath: string; onNodeClick?: (node: TreeNodeItem) => void; maxDepth: number; } const TreeSection: React.FC = ({ section, autoExpandActive, currentPath, onNodeClick, maxDepth, }) => { if (section.visible === false) { return null; } return (
{section.title}
{section.children.map((node, index) => ( ))}
); }; // ============================================================================= // MAIN COMPONENT // ============================================================================= export const TreeNavigation: React.FC = ({ items, autoExpandActive = true, onNodeClick, maxDepth = 0, className = '', }) => { const location = useLocation(); const currentPath = location.pathname; return ( ); }; export default TreeNavigation;