feat(ui): add TreeView component for hierarchical data (#9911)

This commit is contained in:
Alejandro Bailo
2026-02-10 15:26:07 +01:00
committed by GitHub
parent dde42b6a84
commit a12cb5b6d6
13 changed files with 965 additions and 5 deletions
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { CloudIcon, FolderIcon, ServerIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { TreeView } from "@/components/shadcn/tree-view";
import { TreeDataItem } from "@/types/tree";
/**
* Demo page for the TreeView component.
*
* ⚠️ DEVELOPMENT ONLY - This page is for component demonstration and testing.
* It returns 404 in production environments.
*
* Showcases:
* 1. TreeView with checkboxes and hierarchical selection
* 2. TreeView without checkboxes (navigation mode)
* 3. Custom rendering with renderItem prop
*/
// Hide in production - evaluated at build time
const IS_DEV = process.env.NODE_ENV === "development";
const accountsTreeData: TreeDataItem[] = [
{
id: "org-1",
name: "Organization Root",
icon: ServerIcon,
children: [
{
id: "ou-1",
name: "ou-996789098 (Production)",
icon: FolderIcon,
status: "success",
children: [
{
id: "acc-1",
name: "123456789098",
icon: CloudIcon,
status: "success",
},
{
id: "acc-2",
name: "123456789099",
icon: CloudIcon,
status: "success",
},
{
id: "acc-3",
name: "123456789100",
icon: CloudIcon,
status: "success",
},
],
},
{
id: "ou-2",
name: "ou-996789099 (Development)",
icon: FolderIcon,
status: "error",
children: [
{
id: "acc-4",
name: "223456789098",
icon: CloudIcon,
status: "success",
},
{
id: "acc-5",
name: "223456789099",
icon: CloudIcon,
status: "error",
},
],
},
{
id: "ou-3",
name: "ou-996789100 (Staging)",
icon: FolderIcon,
isLoading: true,
children: [{ id: "acc-6", name: "323456789098", icon: CloudIcon }],
},
],
},
];
export default function DemoTreeViewPage() {
// Return 404 in production - this page is for development only
if (!IS_DEV) {
notFound();
}
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expandedIds, setExpandedIds] = useState<string[]>(["org-1"]);
return (
<div className="container mx-auto space-y-12 p-8">
<h1 className="text-3xl font-bold">TreeView Component Demo</h1>
{/* TreeView with Checkboxes */}
<section className="space-y-4">
<div>
<h2 className="text-xl font-semibold">
TreeView with Checkboxes (Account Selector)
</h2>
<p className="text-text-neutral-secondary text-sm">
Select accounts hierarchically. Selecting a parent selects all
children.
</p>
</div>
<div className="flex items-start gap-8">
<div className="bg-bg-neutral-secondary w-96 rounded-lg border p-4">
<TreeView
data={accountsTreeData}
showCheckboxes
enableSelectChildren
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
expandedIds={expandedIds}
onExpandedChange={setExpandedIds}
renderItem={({ item, isLeaf, hasChildren }) => (
<div className="flex min-w-0 flex-1 items-center gap-2">
{item.icon && <item.icon className="h-4 w-4 shrink-0" />}
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate text-base">{item.name}</span>
</TooltipTrigger>
<TooltipContent side="top">{item.name}</TooltipContent>
</Tooltip>
{hasChildren && !isLeaf && (
<span className="bg-prowler-white/10 inline-flex min-w-5 shrink-0 items-center justify-center rounded px-1 py-0.5 text-xs tabular-nums">
{item.children?.length}
</span>
)}
</div>
)}
/>
</div>
<div className="flex-1 space-y-2">
<h3 className="font-medium">Selected IDs:</h3>
<pre className="bg-bg-neutral-tertiary overflow-auto rounded p-4 text-sm">
{JSON.stringify(selectedIds, null, 2)}
</pre>
</div>
</div>
</section>
{/* TreeView without Checkboxes */}
<section className="space-y-4">
<div>
<h2 className="text-xl font-semibold">
TreeView without Checkboxes (Navigation)
</h2>
<p className="text-text-neutral-secondary text-sm">
Click to expand/collapse. Use arrow keys to navigate.
</p>
</div>
<div className="bg-bg-neutral-secondary w-96 rounded-lg border p-4">
<TreeView
data={accountsTreeData}
showCheckboxes={false}
expandAll={false}
/>
</div>
</section>
</div>
);
}
+40 -5
View File
@@ -1,24 +1,54 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { CheckIcon, MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
const SIZE_STYLES = {
default: {
root: "size-6",
icon: "size-4",
},
sm: {
root: "size-5",
icon: "size-3.5",
},
} as const;
type CheckboxSize = keyof typeof SIZE_STYLES;
interface CheckboxProps
extends React.ComponentProps<typeof CheckboxPrimitive.Root> {
/** Size variant: "default" (24px) or "sm" (20px) */
size?: CheckboxSize;
/** Show indeterminate state (minus icon) - used for partial selection in trees */
indeterminate?: boolean;
}
function Checkbox({
className,
size = "default",
indeterminate,
checked,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
}: CheckboxProps) {
const sizeStyles = SIZE_STYLES[size];
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
checked={indeterminate ? "indeterminate" : checked}
className={cn(
// Base styles - 24x24px
"peer size-6 shrink-0 rounded-sm border transition-all outline-none",
// Base styles
"peer shrink-0 rounded-sm border transition-all outline-none",
sizeStyles.root,
// Default state
"bg-bg-input-primary border-border-input-primary shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]",
// Checked state
"data-[state=checked]:bg-button-primary data-[state=checked]:border-button-primary data-[state=checked]:text-white",
// Indeterminate state
"data-[state=indeterminate]:bg-button-primary data-[state=indeterminate]:border-button-primary data-[state=indeterminate]:text-white",
// Focus state
"focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press/50 focus-visible:ring-2",
// Disabled state
@@ -31,10 +61,15 @@ function Checkbox({
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-4" />
{indeterminate ? (
<MinusIcon className={sizeStyles.icon} />
) : (
<CheckIcon className={sizeStyles.icon} />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
export type { CheckboxProps, CheckboxSize };
+2
View File
@@ -0,0 +1,2 @@
export type { CheckboxProps, CheckboxSize } from "./checkbox";
export { Checkbox } from "./checkbox";
+13
View File
@@ -0,0 +1,13 @@
export { TreeItemLabel } from "./tree-item-label";
export { TreeLeaf } from "./tree-leaf";
export { TreeNode } from "./tree-node";
export { TreeSpinner } from "./tree-spinner";
export { TreeStatusIcon } from "./tree-status-icon";
export { TreeView } from "./tree-view";
export {
getAllDescendantIds,
getTreeLeafPadding,
getTreeNodePadding,
TREE_INDENT_REM,
TREE_LEAF_EXTRA_PADDING_REM,
} from "./utils";
@@ -0,0 +1,42 @@
"use client";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { TreeDataItem } from "@/types/tree";
interface TreeItemLabelProps {
/** The tree item to display */
item: TreeDataItem;
/** Optional text size class (defaults to text-base) */
textClassName?: string;
}
/**
* TreeItemLabel component - displays an item's icon and name with truncation.
*
* Features:
* - Optional icon rendering
* - Text truncation with tooltip on hover
* - Consistent layout across tree nodes and leaves
*
* This component extracts the common pattern used in TreeNode and TreeLeaf
* for displaying item content with overflow handling.
*/
export function TreeItemLabel({ item, textClassName }: TreeItemLabelProps) {
return (
<div className="flex min-w-0 flex-1 items-center gap-2">
{item.icon && <item.icon className="h-4 w-4 shrink-0" />}
<Tooltip>
<TooltipTrigger asChild>
<span className={textClassName ?? "truncate text-base"}>
{item.name}
</span>
</TooltipTrigger>
<TooltipContent side="top">{item.name}</TooltipContent>
</Tooltip>
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
import { KeyboardEvent } from "react";
import { Checkbox } from "@/components/shadcn/checkbox";
import { cn } from "@/lib/utils";
import { TreeLeafProps } from "@/types/tree";
import { TreeItemLabel } from "./tree-item-label";
import { TreeSpinner } from "./tree-spinner";
import { TreeStatusIcon } from "./tree-status-icon";
import { getTreeLeafPadding } from "./utils";
/**
* TreeLeaf component for rendering leaf nodes (nodes without children).
*
* Features:
* - Selection via checkbox or click
* - Loading spinner state
* - Custom rendering via renderItem prop
* - Indentation based on nesting level
* - Keyboard navigation support (Enter/Space to select)
*/
export function TreeLeaf({
item,
level,
selectedIds,
onSelectionChange,
showCheckboxes,
renderItem,
}: TreeLeafProps) {
const isSelected = selectedIds.includes(item.id);
const handleSelect = () => {
if (!item.disabled) {
onSelectionChange(item.id, item);
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
handleSelect();
}
};
return (
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5",
"hover:bg-prowler-white/5 cursor-pointer",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
isSelected && "bg-prowler-white/10",
item.disabled && "cursor-not-allowed opacity-50",
item.className,
)}
style={{ paddingLeft: getTreeLeafPadding(level) }}
onClick={handleSelect}
onKeyDown={handleKeyDown}
role="treeitem"
tabIndex={item.disabled ? -1 : 0}
aria-selected={isSelected}
aria-disabled={item.disabled}
>
{item.isLoading && <TreeSpinner />}
{!item.isLoading && item.status && (
<TreeStatusIcon status={item.status} />
)}
{showCheckboxes && (
<Checkbox
size="sm"
checked={isSelected}
onCheckedChange={handleSelect}
disabled={item.disabled}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
/>
)}
{renderItem ? (
renderItem({
item,
level,
isLeaf: true,
isSelected,
hasChildren: false,
})
) : (
<TreeItemLabel item={item} />
)}
</div>
);
}
@@ -0,0 +1,203 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronRightIcon } from "lucide-react";
import { KeyboardEvent } from "react";
import { Checkbox } from "@/components/shadcn/checkbox";
import { cn } from "@/lib/utils";
import { TreeNodeProps } from "@/types/tree";
import { TreeItemLabel } from "./tree-item-label";
import { TreeLeaf } from "./tree-leaf";
import { TreeSpinner } from "./tree-spinner";
import { TreeStatusIcon } from "./tree-status-icon";
import { getAllDescendantIds, getTreeNodePadding } from "./utils";
/**
* TreeNode component for rendering expandable nodes with children.
*
* Features:
* - Collapsible content using Radix UI
* - Indeterminate checkbox state when partially selected
* - Recursive selection of all descendants
* - Loading spinner state
* - Custom rendering via renderItem prop
* - Keyboard navigation support (Enter/Space to select, Arrow keys to expand)
*/
export function TreeNode({
item,
level,
selectedIds,
expandedIds,
onSelectionChange,
onExpandedChange,
showCheckboxes,
renderItem,
expandAll,
enableSelectChildren,
}: TreeNodeProps) {
const isExpanded = expandAll || expandedIds.includes(item.id);
const isSelected = selectedIds.includes(item.id);
// Calculate indeterminate state based on descendant selection
const descendantIds = getAllDescendantIds(item);
const selectedDescendantCount = descendantIds.filter((id) =>
selectedIds.includes(id),
).length;
const isIndeterminate =
selectedDescendantCount > 0 &&
selectedDescendantCount < descendantIds.length;
const handleToggleExpand = () => {
const newExpandedIds = isExpanded
? expandedIds.filter((id) => id !== item.id)
: [...expandedIds, item.id];
onExpandedChange(newExpandedIds);
};
const handleSelect = () => {
onSelectionChange(item.id, item);
};
const handleContentClick = showCheckboxes ? handleSelect : handleToggleExpand;
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case "Enter":
case " ":
event.preventDefault();
handleContentClick();
break;
case "ArrowRight":
if (!isExpanded) {
event.preventDefault();
handleToggleExpand();
}
break;
case "ArrowLeft":
if (isExpanded) {
event.preventDefault();
handleToggleExpand();
}
break;
}
};
return (
<div>
<div
className={cn(
"flex items-center gap-2 rounded-md px-2 py-1.5",
"hover:bg-prowler-white/5 cursor-pointer",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
isSelected && "bg-prowler-white/10",
item.disabled && "cursor-not-allowed opacity-50",
item.className,
)}
style={{ paddingLeft: getTreeNodePadding(level) }}
role="treeitem"
tabIndex={item.disabled ? -1 : 0}
aria-expanded={isExpanded}
aria-selected={isSelected}
aria-disabled={item.disabled}
onClick={handleContentClick}
onKeyDown={handleKeyDown}
>
<button
className="hover:bg-prowler-white/10 shrink-0 rounded p-0.5"
aria-label={isExpanded ? "Collapse" : "Expand"}
onClick={(e) => {
e.stopPropagation();
handleToggleExpand();
}}
tabIndex={-1}
>
{item.isLoading ? (
<TreeSpinner />
) : (
<ChevronRightIcon
className={cn(
"h-4 w-4 transition-transform duration-200",
isExpanded && "rotate-90",
)}
/>
)}
</button>
{!item.isLoading && item.status && (
<TreeStatusIcon status={item.status} />
)}
{showCheckboxes && (
<Checkbox
size="sm"
checked={isSelected}
indeterminate={isIndeterminate && !isSelected}
onCheckedChange={handleSelect}
disabled={item.disabled}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
/>
)}
<div className="min-w-0 flex-1">
{renderItem ? (
renderItem({
item,
level,
isLeaf: false,
isSelected,
isExpanded,
isIndeterminate,
hasChildren: true,
})
) : (
<TreeItemLabel item={item} />
)}
</div>
</div>
<AnimatePresence initial={false} mode="sync">
{isExpanded && (
<motion.ul
key={`children-${item.id}`}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="mt-1 space-y-1 overflow-hidden"
role="group"
>
{item.children?.map((child) => (
<li key={child.id}>
{child.children ? (
<TreeNode
item={child}
level={level + 1}
selectedIds={selectedIds}
expandedIds={expandedIds}
onSelectionChange={onSelectionChange}
onExpandedChange={onExpandedChange}
showCheckboxes={showCheckboxes}
renderItem={renderItem}
expandAll={expandAll}
enableSelectChildren={enableSelectChildren}
/>
) : (
<TreeLeaf
item={child}
level={level + 1}
selectedIds={selectedIds}
onSelectionChange={onSelectionChange}
showCheckboxes={showCheckboxes}
renderItem={renderItem}
/>
)}
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,50 @@
"use client";
import { cn } from "@/lib/utils";
interface TreeSpinnerProps {
className?: string;
}
/**
* TreeSpinner component - a circular loading indicator for tree nodes.
*
* Features:
* - 20x20 (size-5) default size to match checkbox sm
* - 2.5px stroke for good visibility
* - Uses button-primary color
* - Smooth rotation animation
*/
export function TreeSpinner({ className }: TreeSpinnerProps) {
return (
<svg
className={cn("size-5 shrink-0 animate-spin", className)}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Loading"
>
{/* Background track */}
<circle
cx="10"
cy="10"
r="7.5"
className="stroke-button-primary/20"
strokeWidth="2.5"
fill="none"
/>
{/* Animated arc */}
<circle
cx="10"
cy="10"
r="7.5"
className="stroke-button-primary"
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
strokeDasharray="47.12"
strokeDashoffset="35.34"
/>
</svg>
);
}
@@ -0,0 +1,41 @@
"use client";
import { CircleCheckIcon, CircleXIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { TREE_ITEM_STATUS, TreeItemStatus } from "@/types/tree";
interface TreeStatusIconProps {
status: TreeItemStatus;
className?: string;
}
/**
* TreeStatusIcon component - displays success or error status for tree nodes.
*
* Features:
* - CircleCheck icon for success (green)
* - CircleX icon for error (red)
* - Same size as TreeSpinner for consistent layout
*/
export function TreeStatusIcon({ status, className }: TreeStatusIconProps) {
if (status === TREE_ITEM_STATUS.SUCCESS) {
return (
<CircleCheckIcon
className={cn("text-text-success-primary size-5 shrink-0", className)}
aria-label="Success"
/>
);
}
if (status === TREE_ITEM_STATUS.ERROR) {
return (
<CircleXIcon
className={cn("text-text-error-primary size-5 shrink-0", className)}
aria-label="Error"
/>
);
}
return null;
}
@@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { TreeDataItem, TreeViewProps } from "@/types/tree";
import { TreeLeaf } from "./tree-leaf";
import { TreeNode } from "./tree-node";
import { getAllDescendantIds } from "./utils";
/**
* TreeView component for rendering hierarchical data structures.
*
* Features:
* - Recursive nested structure support
* - Controlled or uncontrolled selection state
* - Controlled or uncontrolled expansion state
* - Auto-select children when parent is selected (optional)
* - Indeterminate checkbox state for partial selection
* - Loading states with spinners
* - Custom rendering via renderItem prop
*
* @example
* ```tsx
* const data: TreeDataItem[] = [
* {
* id: "org-1",
* name: "Organization",
* children: [
* { id: "acc-1", name: "Account 1" },
* { id: "acc-2", name: "Account 2" },
* ],
* },
* ];
*
* <TreeView
* data={data}
* showCheckboxes
* enableSelectChildren
* selectedIds={selectedAccounts}
* onSelectionChange={setSelectedAccounts}
* />
* ```
*/
export function TreeView({
data,
className,
selectedIds: controlledSelectedIds,
onSelectionChange,
expandedIds: controlledExpandedIds,
onExpandedChange,
expandAll = false,
showCheckboxes = false,
enableSelectChildren = true,
renderItem,
}: TreeViewProps) {
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>([]);
const [internalExpandedIds, setInternalExpandedIds] = useState<string[]>([]);
const selectedIds = controlledSelectedIds ?? internalSelectedIds;
const expandedIds = controlledExpandedIds ?? internalExpandedIds;
const handleSelectionChange = (itemId: string, item: TreeDataItem) => {
const isSelected = selectedIds.includes(itemId);
let newSelectedIds: string[];
if (enableSelectChildren && item.children) {
// When selecting a parent, also select/deselect all descendants
const allItemIds = getAllDescendantIds(item, true);
if (isSelected) {
// Deselect this item and all descendants
newSelectedIds = selectedIds.filter((id) => !allItemIds.includes(id));
} else {
// Select this item and all descendants
newSelectedIds = Array.from(new Set([...selectedIds, ...allItemIds]));
}
} else {
// Simple toggle without affecting children
newSelectedIds = isSelected
? selectedIds.filter((id) => id !== itemId)
: [...selectedIds, itemId];
}
if (onSelectionChange) {
onSelectionChange(newSelectedIds);
} else {
setInternalSelectedIds(newSelectedIds);
}
};
const handleExpandedChange = (newExpandedIds: string[]) => {
if (onExpandedChange) {
onExpandedChange(newExpandedIds);
} else {
setInternalExpandedIds(newExpandedIds);
}
};
const items = Array.isArray(data) ? data : [data];
return (
<div
className={cn("relative overflow-hidden p-2", className)}
role="tree"
aria-multiselectable={showCheckboxes}
>
<ul className="space-y-1">
{items.map((item) => (
<li key={item.id}>
{item.children ? (
<TreeNode
item={item}
level={0}
selectedIds={selectedIds}
expandedIds={expandedIds}
onSelectionChange={handleSelectionChange}
onExpandedChange={handleExpandedChange}
showCheckboxes={showCheckboxes}
renderItem={renderItem}
expandAll={expandAll}
enableSelectChildren={enableSelectChildren}
/>
) : (
<TreeLeaf
item={item}
level={0}
selectedIds={selectedIds}
onSelectionChange={handleSelectionChange}
showCheckboxes={showCheckboxes}
renderItem={renderItem}
/>
)}
</li>
))}
</ul>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { TreeDataItem } from "@/types/tree";
/**
* Tree indentation constants (in rem units).
* Used to calculate consistent padding for nested tree items.
*/
export const TREE_INDENT_REM = 1.25;
export const TREE_LEAF_EXTRA_PADDING_REM = 1.5;
/**
* Calculates the left padding for a tree node based on its nesting level.
*/
export function getTreeNodePadding(level: number): string {
return `${level * TREE_INDENT_REM}rem`;
}
/**
* Calculates the left padding for a tree leaf based on its nesting level.
* Leaves have extra padding to align with node content (accounting for expand button).
*/
export function getTreeLeafPadding(level: number): string {
return `${level * TREE_INDENT_REM + TREE_LEAF_EXTRA_PADDING_REM}rem`;
}
/**
* Recursively collects all descendant IDs from a tree item.
*
* @param item - The tree item to collect IDs from
* @param includeParent - Whether to include the parent item's ID (default: false)
* @returns Array of all descendant IDs (and optionally the parent ID)
*
* @example
* Get only descendant IDs
* const childIds = getAllDescendantIds(parentItem);
*
* Get parent + all descendant IDs
* const allIds = getAllDescendantIds(parentItem, true);
*/
export function getAllDescendantIds(
item: TreeDataItem,
includeParent = false,
): string[] {
const ids = includeParent ? [item.id] : [];
if (item.children) {
for (const child of item.children) {
ids.push(child.id, ...getAllDescendantIds(child, false));
}
}
return ids;
}
+1
View File
@@ -6,3 +6,4 @@ export * from "./processors";
export * from "./providers";
export * from "./resources";
export * from "./scans";
export * from "./tree";
+114
View File
@@ -0,0 +1,114 @@
/**
* Tree View Component Types
*
* Types for the TreeView component used to render hierarchical data structures
* with support for selection, expansion, and custom rendering.
*/
/**
* Status indicator for tree items after loading completes
*/
export const TREE_ITEM_STATUS = {
SUCCESS: "success",
ERROR: "error",
} as const;
export type TreeItemStatus =
(typeof TREE_ITEM_STATUS)[keyof typeof TREE_ITEM_STATUS];
/**
* Represents a single item in the tree structure.
* Items can have nested children to create a hierarchical tree.
*/
export interface TreeDataItem {
/** Unique identifier for the tree item */
id: string;
/** Display name for the item */
name: string;
/** Optional icon component to render alongside the name */
icon?: React.ComponentType<{ className?: string }>;
/** Child items (if present, this node is expandable) */
children?: TreeDataItem[];
/** Whether the item is disabled (cannot be selected) */
disabled?: boolean;
/** Whether the item is in a loading state (shows spinner) */
isLoading?: boolean;
/** Status indicator shown after loading (success/error) */
status?: TreeItemStatus;
/** Additional CSS classes for the item */
className?: string;
}
/**
* Props for the main TreeView component
*/
export interface TreeViewProps {
/** Tree data - can be a single root or array of roots */
data: TreeDataItem[] | TreeDataItem;
/** Additional CSS classes for the root container */
className?: string;
/** Controlled selected item IDs */
selectedIds?: string[];
/** Callback when selection changes */
onSelectionChange?: (selectedIds: string[]) => void;
/** Controlled expanded item IDs */
expandedIds?: string[];
/** Callback when expansion state changes */
onExpandedChange?: (expandedIds: string[]) => void;
/** Expand all nodes by default */
expandAll?: boolean;
/** Show checkboxes for selection */
showCheckboxes?: boolean;
/** Auto-select children when parent is selected */
enableSelectChildren?: boolean;
/** Custom render function for each item */
renderItem?: (params: TreeRenderItemParams) => React.ReactNode;
}
/**
* Parameters passed to the custom renderItem function
*/
export interface TreeRenderItemParams {
/** The tree item being rendered */
item: TreeDataItem;
/** Nesting depth level (0 = root) */
level: number;
/** Whether this is a leaf node (no children) */
isLeaf: boolean;
/** Whether this item is selected */
isSelected: boolean;
/** Whether this item is expanded (only for non-leaf nodes) */
isExpanded?: boolean;
/** Whether this item has partial child selection (indeterminate state) */
isIndeterminate?: boolean;
/** Whether this item has children */
hasChildren: boolean;
}
/**
* Internal props for TreeNode component (expandable nodes)
*/
export interface TreeNodeProps {
item: TreeDataItem;
level: number;
selectedIds: string[];
expandedIds: string[];
onSelectionChange: (id: string, item: TreeDataItem) => void;
onExpandedChange: (ids: string[]) => void;
showCheckboxes: boolean;
renderItem?: (params: TreeRenderItemParams) => React.ReactNode;
expandAll: boolean;
enableSelectChildren: boolean;
}
/**
* Internal props for TreeLeaf component (non-expandable nodes)
*/
export interface TreeLeafProps {
item: TreeDataItem;
level: number;
selectedIds: string[];
onSelectionChange: (id: string, item: TreeDataItem) => void;
showCheckboxes: boolean;
renderItem?: (params: TreeRenderItemParams) => React.ReactNode;
}