Feat: Add TreeView component #3221 (#6214)

### What problem does this PR solve?

Feat: Add TreeView component #3221

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
This commit is contained in:
balibabu 2025-03-18 14:03:12 +08:00 committed by GitHub
parent 09291db805
commit 57cbefa589
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 537 additions and 0 deletions

120
web/package-lock.json generated
View File

@ -15,6 +15,7 @@
"@js-preview/excel": "^1.7.8",
"@lexical/react": "^0.23.1",
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.1",
@ -4563,6 +4564,125 @@
"resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.0.tgz",
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-accordion/-/react-accordion-1.2.3.tgz",
"integrity": "sha512-RIQ15mrcvqIkDARJeERSuXSry2N8uYnxkdDetpfmalT/+0ntOXLkFOsh9iwlAsCv+qcmhZjbdJogIm6WBa6c4A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collapsible": "1.1.3",
"@radix-ui/react-collection": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",
"integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.4.tgz",

View File

@ -26,6 +26,7 @@
"@js-preview/excel": "^1.7.8",
"@lexical/react": "^0.23.1",
"@monaco-editor/react": "^4.6.0",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-aspect-ratio": "^1.1.0",
"@radix-ui/react-avatar": "^1.1.1",

View File

@ -0,0 +1,58 @@
'use client';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { ChevronDown } from 'lucide-react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn('border-b', className)}
{...props}
/>
));
AccordionItem.displayName = 'AccordionItem';
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn('pb-4 pt-0', className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@ -0,0 +1,358 @@
'use client';
import { cn } from '@/lib/utils';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { cva } from 'class-variance-authority';
import { ChevronRight } from 'lucide-react';
import React from 'react';
const treeVariants = cva(
'group hover:before:opacity-100 before:absolute before:rounded-lg before:left-0 px-2 before:w-full before:opacity-0 before:bg-accent/70 before:h-[2rem] before:-z-10',
);
const selectedTreeVariants = cva(
'before:opacity-100 before:bg-accent/70 text-accent-foreground',
);
interface TreeDataItem {
id: string;
name: string;
icon?: any;
selectedIcon?: any;
openIcon?: any;
children?: TreeDataItem[];
actions?: React.ReactNode;
onClick?: () => void;
}
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header>
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
'flex flex-1 w-full items-center py-2 transition-all first:[&[data-state=open]>svg]:rotate-90',
className,
)}
{...props}
>
<ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200 text-accent-foreground/50 mr-1" />
{children}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
'overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down',
className,
)}
{...props}
>
<div className="pb-1 pt-0">{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
const TreeIcon = ({
item,
isOpen,
isSelected,
default: defaultIcon,
}: {
item: TreeDataItem;
isOpen?: boolean;
isSelected?: boolean;
default?: any;
}) => {
let Icon = defaultIcon;
if (isSelected && item.selectedIcon) {
Icon = item.selectedIcon;
} else if (isOpen && item.openIcon) {
Icon = item.openIcon;
} else if (item.icon) {
Icon = item.icon;
}
return Icon ? <Icon className="h-4 w-4 shrink-0 mr-2" /> : <></>;
};
const TreeActions = ({
children,
isSelected,
}: {
children: React.ReactNode;
isSelected: boolean;
}) => {
return (
<div
className={cn(
isSelected ? 'block' : 'hidden',
'absolute right-3 group-hover:block',
)}
>
{children}
</div>
);
};
const TreeNode = ({
item,
handleSelectChange,
expandedItemIds,
selectedItemId,
defaultNodeIcon,
defaultLeafIcon,
}: {
item: TreeDataItem;
handleSelectChange: (item: TreeDataItem | undefined) => void;
expandedItemIds: string[];
selectedItemId?: string;
defaultNodeIcon?: any;
defaultLeafIcon?: any;
}) => {
const [value, setValue] = React.useState(
expandedItemIds.includes(item.id) ? [item.id] : [],
);
return (
<AccordionPrimitive.Root
type="multiple"
value={value}
onValueChange={(s) => setValue(s)}
>
<AccordionPrimitive.Item value={item.id}>
<AccordionTrigger
className={cn(
treeVariants(),
selectedItemId === item.id && selectedTreeVariants(),
)}
onClick={() => {
handleSelectChange(item);
item.onClick?.();
}}
>
<TreeIcon
item={item}
isSelected={selectedItemId === item.id}
isOpen={value.includes(item.id)}
default={defaultNodeIcon}
/>
<span className="text-sm truncate">{item.name}</span>
<TreeActions isSelected={selectedItemId === item.id}>
{item.actions}
</TreeActions>
</AccordionTrigger>
<AccordionContent className="ml-4 pl-1 border-l">
<TreeItem
data={item.children ? item.children : item}
selectedItemId={selectedItemId}
handleSelectChange={handleSelectChange}
expandedItemIds={expandedItemIds}
defaultLeafIcon={defaultLeafIcon}
defaultNodeIcon={defaultNodeIcon}
/>
</AccordionContent>
</AccordionPrimitive.Item>
</AccordionPrimitive.Root>
);
};
type TreeItemProps = TreeProps & {
selectedItemId?: string;
handleSelectChange: (item: TreeDataItem | undefined) => void;
expandedItemIds: string[];
defaultNodeIcon?: any;
defaultLeafIcon?: any;
};
const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
(
{
className,
data,
selectedItemId,
handleSelectChange,
expandedItemIds,
defaultNodeIcon,
defaultLeafIcon,
...props
},
ref,
) => {
if (!(data instanceof Array)) {
data = [data];
}
return (
<div ref={ref} role="tree" className={className} {...props}>
<ul>
{data.map((item) => (
<li key={item.id}>
{item.children ? (
<TreeNode
item={item}
selectedItemId={selectedItemId}
expandedItemIds={expandedItemIds}
handleSelectChange={handleSelectChange}
defaultNodeIcon={defaultNodeIcon}
defaultLeafIcon={defaultLeafIcon}
/>
) : (
<TreeLeaf
item={item}
selectedItemId={selectedItemId}
handleSelectChange={handleSelectChange}
defaultLeafIcon={defaultLeafIcon}
/>
)}
</li>
))}
</ul>
</div>
);
},
);
TreeItem.displayName = 'TreeItem';
const TreeLeaf = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
item: TreeDataItem;
selectedItemId?: string;
handleSelectChange: (item: TreeDataItem | undefined) => void;
defaultLeafIcon?: any;
}
>(
(
{
className,
item,
selectedItemId,
handleSelectChange,
defaultLeafIcon,
...props
},
ref,
) => {
return (
<div
ref={ref}
className={cn(
'ml-5 flex text-left items-center py-2 cursor-pointer before:right-1',
treeVariants(),
className,
selectedItemId === item.id && selectedTreeVariants(),
)}
onClick={() => {
handleSelectChange(item);
item.onClick?.();
}}
{...props}
>
<TreeIcon
item={item}
isSelected={selectedItemId === item.id}
default={defaultLeafIcon}
/>
<span className="flex-grow text-sm truncate">{item.name}</span>
<TreeActions isSelected={selectedItemId === item.id}>
{item.actions}
</TreeActions>
</div>
);
},
);
TreeLeaf.displayName = 'TreeLeaf';
type TreeProps = React.HTMLAttributes<HTMLDivElement> & {
data: TreeDataItem[] | TreeDataItem;
initialSelectedItemId?: string;
onSelectChange?: (item: TreeDataItem | undefined) => void;
expandAll?: boolean;
defaultNodeIcon?: any;
defaultLeafIcon?: any;
};
const TreeView = React.forwardRef<HTMLDivElement, TreeProps>(
(
{
data,
initialSelectedItemId,
onSelectChange,
expandAll,
defaultLeafIcon,
defaultNodeIcon,
className,
...props
},
ref,
) => {
const [selectedItemId, setSelectedItemId] = React.useState<
string | undefined
>(initialSelectedItemId);
const handleSelectChange = React.useCallback(
(item: TreeDataItem | undefined) => {
setSelectedItemId(item?.id);
if (onSelectChange) {
onSelectChange(item);
}
},
[onSelectChange],
);
const expandedItemIds = React.useMemo(() => {
if (!initialSelectedItemId) {
return [] as string[];
}
const ids: string[] = [];
function walkTreeItems(
items: TreeDataItem[] | TreeDataItem,
targetId: string,
) {
if (items instanceof Array) {
for (let i = 0; i < items.length; i++) {
ids.push(items[i]!.id);
if (walkTreeItems(items[i]!, targetId) && !expandAll) {
return true;
}
if (!expandAll) ids.pop();
}
} else if (!expandAll && items.id === targetId) {
return true;
} else if (items.children) {
return walkTreeItems(items.children, targetId);
}
}
walkTreeItems(data, initialSelectedItemId);
return ids;
}, [data, expandAll, initialSelectedItemId]);
return (
<div className={cn('overflow-hidden relative p-2', className)}>
<TreeItem
data={data}
ref={ref}
selectedItemId={selectedItemId}
handleSelectChange={handleSelectChange}
expandedItemIds={expandedItemIds}
defaultLeafIcon={defaultLeafIcon}
defaultNodeIcon={defaultNodeIcon}
{...props}
/>
</div>
);
},
);
TreeView.displayName = 'TreeView';
export { TreeView, type TreeDataItem };