diff --git a/web/package-lock.json b/web/package-lock.json index 52a7f3e22..399c18861 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 60d9fbcfb..3ca80dd67 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/components/ui/accordion.tsx b/web/src/components/ui/accordion.tsx new file mode 100644 index 000000000..347afa7d5 --- /dev/null +++ b/web/src/components/ui/accordion.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; diff --git a/web/src/components/ui/tree-view.tsx b/web/src/components/ui/tree-view.tsx new file mode 100644 index 000000000..68d29d5a4 --- /dev/null +++ b/web/src/components/ui/tree-view.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-90', + className, + )} + {...props} + > + + {children} + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +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 ? : <>; +}; + +const TreeActions = ({ + children, + isSelected, +}: { + children: React.ReactNode; + isSelected: boolean; +}) => { + return ( +
+ {children} +
+ ); +}; + +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 ( + setValue(s)} + > + + { + handleSelectChange(item); + item.onClick?.(); + }} + > + + {item.name} + + {item.actions} + + + + + + + + ); +}; + +type TreeItemProps = TreeProps & { + selectedItemId?: string; + handleSelectChange: (item: TreeDataItem | undefined) => void; + expandedItemIds: string[]; + defaultNodeIcon?: any; + defaultLeafIcon?: any; +}; + +const TreeItem = React.forwardRef( + ( + { + className, + data, + selectedItemId, + handleSelectChange, + expandedItemIds, + defaultNodeIcon, + defaultLeafIcon, + ...props + }, + ref, + ) => { + if (!(data instanceof Array)) { + data = [data]; + } + return ( +
+
    + {data.map((item) => ( +
  • + {item.children ? ( + + ) : ( + + )} +
  • + ))} +
+
+ ); + }, +); +TreeItem.displayName = 'TreeItem'; + +const TreeLeaf = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & { + item: TreeDataItem; + selectedItemId?: string; + handleSelectChange: (item: TreeDataItem | undefined) => void; + defaultLeafIcon?: any; + } +>( + ( + { + className, + item, + selectedItemId, + handleSelectChange, + defaultLeafIcon, + ...props + }, + ref, + ) => { + return ( +
{ + handleSelectChange(item); + item.onClick?.(); + }} + {...props} + > + + {item.name} + + {item.actions} + +
+ ); + }, +); +TreeLeaf.displayName = 'TreeLeaf'; + +type TreeProps = React.HTMLAttributes & { + data: TreeDataItem[] | TreeDataItem; + initialSelectedItemId?: string; + onSelectChange?: (item: TreeDataItem | undefined) => void; + expandAll?: boolean; + defaultNodeIcon?: any; + defaultLeafIcon?: any; +}; + +const TreeView = React.forwardRef( + ( + { + 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 ( +
+ +
+ ); + }, +); +TreeView.displayName = 'TreeView'; + +export { TreeView, type TreeDataItem };