mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
206 lines
6.0 KiB
TypeScript
206 lines
6.0 KiB
TypeScript
"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 { TreeStatusIndicator } from "./tree-status-indicator";
|
|
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,
|
|
enableSelectChildren,
|
|
}: TreeNodeProps) {
|
|
const isExpanded = expandedIds.includes(item.id);
|
|
const isSelected = selectedIds.includes(item.id);
|
|
const statusIcon =
|
|
!item.isLoading && item.status ? (
|
|
<TreeStatusIndicator
|
|
status={item.status}
|
|
errorMessage={item.errorMessage}
|
|
/>
|
|
) : null;
|
|
|
|
// 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",
|
|
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>
|
|
|
|
{statusIcon}
|
|
|
|
{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 && child.children.length > 0 ? (
|
|
<TreeNode
|
|
item={child}
|
|
level={level + 1}
|
|
selectedIds={selectedIds}
|
|
expandedIds={expandedIds}
|
|
onSelectionChange={onSelectionChange}
|
|
onExpandedChange={onExpandedChange}
|
|
showCheckboxes={showCheckboxes}
|
|
renderItem={renderItem}
|
|
enableSelectChildren={enableSelectChildren}
|
|
/>
|
|
) : (
|
|
<TreeLeaf
|
|
item={child}
|
|
level={level + 1}
|
|
selectedIds={selectedIds}
|
|
onSelectionChange={onSelectionChange}
|
|
showCheckboxes={showCheckboxes}
|
|
renderItem={renderItem}
|
|
/>
|
|
)}
|
|
</li>
|
|
))}
|
|
</motion.ul>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|