mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
feat(ui): add TreeView component for hierarchical data (#9911)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CheckboxProps, CheckboxSize } from "./checkbox";
|
||||
export { Checkbox } from "./checkbox";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from "./processors";
|
||||
export * from "./providers";
|
||||
export * from "./resources";
|
||||
export * from "./scans";
|
||||
export * from "./tree";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user