frontend_nyla/src/components/Navigation/TreeNavigation/TreeNavigation.tsx
2026-05-04 09:33:14 +02:00

400 lines
12 KiB
TypeScript

/**
* 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<TreeNodeProps> = ({
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<HTMLDivElement>(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 && (
<span
className={`${styles.toggle} ${isExpanded ? styles.toggleExpanded : ''}`}
onClick={handleToggleClick}
/>
)}
{!isExpandable && hasChildren === false && (
<span className={styles.toggleSpacer} />
)}
{node.icon && <span className={styles.nodeIcon}>{node.icon}</span>}
<span className={styles.nodeLabel} title={node.label}>{node.label}</span>
{node.badge !== undefined && (
<span
className={`${styles.nodeBadge} ${node.badgeVariant ? styles[`badge${node.badgeVariant.charAt(0).toUpperCase() + node.badgeVariant.slice(1)}`] : ''}`}
>
{node.badge}
</span>
)}
</>
);
// 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 ? (
<NavLink
to={node.path}
className={nodeClasses}
onClick={handleClick}
data-id={node.dataId}
data-depth={level}
>
{nodeContent}
</NavLink>
) : (
<button
type="button"
className={nodeClasses}
onClick={handleClick}
disabled={node.disabled}
data-id={node.dataId}
data-depth={level}
>
{nodeContent}
</button>
);
// Check max depth
const canRenderChildren = maxDepth === 0 || level < maxDepth;
return (
<div className={styles.treeNodeContainer} ref={containerRef}>
{nodeElement}
{node.actions && (
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>
{node.actions}
</span>
)}
{isExpanded && hasChildren && canRenderChildren && (
<div className={styles.treeNodeChildren}>
{node.children!.map((child, index) => (
<TreeNode
key={child.id || `${node.id}-child-${index}`}
node={child}
level={level + 1}
parentHasIcon={!!node.icon}
autoExpandActive={autoExpandActive}
currentPath={currentPath}
onNodeClick={onNodeClick}
maxDepth={maxDepth}
/>
))}
</div>
)}
</div>
);
};
// =============================================================================
// TREE SECTION COMPONENT
// =============================================================================
interface TreeSectionProps {
section: TreeSectionItem;
autoExpandActive: boolean;
currentPath: string;
onNodeClick?: (node: TreeNodeItem) => void;
maxDepth: number;
}
const TreeSection: React.FC<TreeSectionProps> = ({
section,
autoExpandActive,
currentPath,
onNodeClick,
maxDepth,
}) => {
if (section.visible === false) {
return null;
}
return (
<div className={styles.treeSection}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>{section.title}</span>
</div>
<div className={styles.sectionContent}>
{section.children.map((node, index) => (
<TreeNode
key={node.id || `section-${section.title}-${index}`}
node={node}
level={0}
parentHasIcon={false}
autoExpandActive={autoExpandActive}
currentPath={currentPath}
onNodeClick={onNodeClick}
maxDepth={maxDepth}
/>
))}
</div>
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export const TreeNavigation: React.FC<TreeNavigationProps> = ({
items,
autoExpandActive = true,
onNodeClick,
maxDepth = 0,
className = '',
}) => {
const location = useLocation();
const currentPath = location.pathname;
return (
<nav className={`${styles.treeNavigation} ${className}`}>
{items.map((item, index) => {
if (isTreeSeparator(item)) {
return <div key={`separator-${index}`} className={styles.separator} />;
}
if (isTreeSection(item)) {
return (
<TreeSection
key={`section-${item.title}-${index}`}
section={item}
autoExpandActive={autoExpandActive}
currentPath={currentPath}
onNodeClick={onNodeClick}
maxDepth={maxDepth}
/>
);
}
if (isTreeNode(item)) {
return (
<TreeNode
key={item.id || `node-${index}`}
node={item}
level={0}
parentHasIcon={false}
autoExpandActive={autoExpandActive}
currentPath={currentPath}
onNodeClick={onNodeClick}
maxDepth={maxDepth}
/>
);
}
return null;
})}
</nav>
);
};
export default TreeNavigation;