400 lines
12 KiB
TypeScript
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;
|