mirror of
https://github.com/chartdb/chartdb.git
synced 2025-10-23 16:13:40 +00:00
462 lines
17 KiB
TypeScript
462 lines
17 KiB
TypeScript
import {
|
|
ChevronRight,
|
|
File,
|
|
Folder,
|
|
Loader2,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { cn } from '@/lib/utils';
|
|
import { Button } from '@/components/button/button';
|
|
import type {
|
|
TreeNode,
|
|
FetchChildrenFunction,
|
|
SelectableTreeProps,
|
|
} from './tree';
|
|
import type { ExpandedState } from './use-tree';
|
|
import { useTree } from './use-tree';
|
|
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { TreeItemSkeleton } from './tree-item-skeleton';
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from '@/components/tooltip/tooltip';
|
|
|
|
interface TreeViewProps<
|
|
Type extends string,
|
|
Context extends Record<Type, unknown>,
|
|
> {
|
|
data: TreeNode<Type, Context>[];
|
|
fetchChildren?: FetchChildrenFunction<Type, Context>;
|
|
onNodeClick?: (node: TreeNode<Type, Context>) => void;
|
|
className?: string;
|
|
defaultIcon?: LucideIcon;
|
|
defaultFolderIcon?: LucideIcon;
|
|
defaultIconProps?: React.ComponentProps<LucideIcon>;
|
|
defaultFolderIconProps?: React.ComponentProps<LucideIcon>;
|
|
selectable?: SelectableTreeProps<Type, Context>;
|
|
expanded?: ExpandedState;
|
|
setExpanded?: Dispatch<SetStateAction<ExpandedState>>;
|
|
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
|
|
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
|
|
loadingNodeIds?: string[];
|
|
}
|
|
|
|
export function TreeView<
|
|
Type extends string,
|
|
Context extends Record<Type, unknown>,
|
|
>({
|
|
data,
|
|
fetchChildren,
|
|
onNodeClick,
|
|
className,
|
|
defaultIcon = File,
|
|
defaultFolderIcon = Folder,
|
|
defaultIconProps,
|
|
defaultFolderIconProps,
|
|
selectable,
|
|
expanded: expandedProp,
|
|
setExpanded: setExpandedProp,
|
|
renderHoverComponent,
|
|
renderActionsComponent,
|
|
loadingNodeIds,
|
|
}: TreeViewProps<Type, Context>) {
|
|
const { expanded, loading, loadedChildren, hasMoreChildren, toggleNode } =
|
|
useTree({
|
|
fetchChildren,
|
|
expanded: expandedProp,
|
|
setExpanded: setExpandedProp,
|
|
});
|
|
const [selectedIdInternal, setSelectedIdInternal] = React.useState<
|
|
string | undefined
|
|
>(selectable?.defaultSelectedId);
|
|
|
|
const selectedId = useMemo(() => {
|
|
return selectable?.selectedId ?? selectedIdInternal;
|
|
}, [selectable?.selectedId, selectedIdInternal]);
|
|
|
|
const setSelectedId = useCallback(
|
|
(value: SetStateAction<string | undefined>) => {
|
|
if (selectable?.setSelectedId) {
|
|
selectable.setSelectedId(value);
|
|
} else {
|
|
setSelectedIdInternal(value);
|
|
}
|
|
},
|
|
[selectable, setSelectedIdInternal]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (selectable?.enabled && selectable.defaultSelectedId) {
|
|
if (selectable.defaultSelectedId === selectedId) return;
|
|
setSelectedId(selectable.defaultSelectedId);
|
|
const { node, path } = findNodeById(
|
|
data,
|
|
selectable.defaultSelectedId
|
|
);
|
|
|
|
if (node) {
|
|
selectable.onSelectedChange?.(node);
|
|
|
|
// Expand all parent nodes
|
|
for (const parent of path) {
|
|
if (expanded[parent.id]) continue;
|
|
toggleNode(
|
|
parent.id,
|
|
parent.type,
|
|
parent.context,
|
|
parent.children
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}, [selectable, toggleNode, selectedId, data, expanded, setSelectedId]);
|
|
|
|
const handleNodeSelect = (node: TreeNode<Type, Context>) => {
|
|
if (selectable?.enabled) {
|
|
setSelectedId(node.id);
|
|
selectable.onSelectedChange?.(node);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn('w-full', className)}>
|
|
{data.map((node, index) => (
|
|
<TreeNode
|
|
key={node.id}
|
|
node={node}
|
|
level={0}
|
|
expanded={expanded}
|
|
loading={loading}
|
|
loadedChildren={loadedChildren}
|
|
hasMoreChildren={hasMoreChildren}
|
|
onToggle={toggleNode}
|
|
onNodeClick={onNodeClick}
|
|
defaultIcon={defaultIcon}
|
|
defaultFolderIcon={defaultFolderIcon}
|
|
defaultIconProps={defaultIconProps}
|
|
defaultFolderIconProps={defaultFolderIconProps}
|
|
selectable={selectable?.enabled}
|
|
selectedId={selectedId}
|
|
onSelect={handleNodeSelect}
|
|
className={index > 0 ? 'mt-0.5' : ''}
|
|
renderHoverComponent={renderHoverComponent}
|
|
renderActionsComponent={renderActionsComponent}
|
|
loadingNodeIds={loadingNodeIds}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface TreeNodeProps<
|
|
Type extends string,
|
|
Context extends Record<Type, unknown>,
|
|
> {
|
|
node: TreeNode<Type, Context>;
|
|
level: number;
|
|
expanded: Record<string, boolean>;
|
|
loading: Record<string, boolean>;
|
|
loadedChildren: Record<string, TreeNode<Type, Context>[]>;
|
|
hasMoreChildren: Record<string, boolean>;
|
|
onToggle: (
|
|
nodeId: string,
|
|
nodeType: Type,
|
|
nodeContext: Context[Type],
|
|
staticChildren?: TreeNode<Type, Context>[]
|
|
) => void;
|
|
onNodeClick?: (node: TreeNode<Type, Context>) => void;
|
|
defaultIcon: LucideIcon;
|
|
defaultFolderIcon: LucideIcon;
|
|
defaultIconProps?: React.ComponentProps<LucideIcon>;
|
|
defaultFolderIconProps?: React.ComponentProps<LucideIcon>;
|
|
selectable?: boolean;
|
|
selectedId?: string;
|
|
onSelect: (node: TreeNode<Type, Context>) => void;
|
|
className?: string;
|
|
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
|
|
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
|
|
loadingNodeIds?: string[];
|
|
}
|
|
|
|
function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
|
|
node,
|
|
level,
|
|
expanded,
|
|
loading,
|
|
loadedChildren,
|
|
hasMoreChildren,
|
|
onToggle,
|
|
onNodeClick,
|
|
defaultIcon: DefaultIcon,
|
|
defaultFolderIcon: DefaultFolderIcon,
|
|
defaultIconProps,
|
|
defaultFolderIconProps,
|
|
selectable,
|
|
selectedId,
|
|
onSelect,
|
|
className,
|
|
renderHoverComponent,
|
|
renderActionsComponent,
|
|
loadingNodeIds,
|
|
}: TreeNodeProps<Type, Context>) {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const isExpanded = expanded[node.id];
|
|
const isLoading = loading[node.id];
|
|
const children = loadedChildren[node.id] || node.children;
|
|
const isSelected = selectedId === node.id;
|
|
|
|
const IconComponent =
|
|
node.icon || (node.isFolder ? DefaultFolderIcon : DefaultIcon);
|
|
const iconProps: React.ComponentProps<LucideIcon> = {
|
|
strokeWidth: isSelected ? 2.5 : 2,
|
|
...(node.isFolder ? defaultFolderIconProps : defaultIconProps),
|
|
...node.iconProps,
|
|
className: cn(
|
|
'h-3.5 w-3.5 text-muted-foreground flex-none',
|
|
isSelected && 'text-primary text-white',
|
|
node.iconProps?.className
|
|
),
|
|
};
|
|
|
|
return (
|
|
<div className={cn(className)}>
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-1.5 px-2 py-1 rounded-lg cursor-pointer group h-6',
|
|
'transition-colors duration-200',
|
|
isSelected
|
|
? 'bg-sky-500 border border-sky-600 border dark:bg-sky-600 dark:border-sky-700'
|
|
: 'hover:bg-gray-200/50 border border-transparent dark:hover:bg-gray-700/50',
|
|
node.className
|
|
)}
|
|
{...(isSelected ? { 'data-selected': true } : {})}
|
|
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (selectable && !node.unselectable) {
|
|
onSelect(node);
|
|
}
|
|
// if (node.isFolder) {
|
|
// onToggle(node.id, node.children);
|
|
// }
|
|
|
|
// called only once in case of double click
|
|
if (e.detail !== 2) {
|
|
onNodeClick?.(node);
|
|
}
|
|
}}
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
if (node.isFolder) {
|
|
onToggle(
|
|
node.id,
|
|
node.type,
|
|
node.context,
|
|
node.children
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<div className="flex flex-none items-center gap-1.5">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(
|
|
'h-3.5 w-3.5 p-0 hover:bg-transparent flex-none',
|
|
isExpanded && 'rotate-90',
|
|
'transition-transform duration-200'
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (node.isFolder) {
|
|
onToggle(
|
|
node.id,
|
|
node.type,
|
|
node.context,
|
|
node.children
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
{node.isFolder &&
|
|
(isLoading ? (
|
|
<Loader2
|
|
className={cn('size-3.5 animate-spin', {
|
|
'text-white': isSelected,
|
|
})}
|
|
/>
|
|
) : (
|
|
<ChevronRight
|
|
className={cn('size-3.5', {
|
|
'text-white': isSelected,
|
|
})}
|
|
strokeWidth={2}
|
|
/>
|
|
))}
|
|
</Button>
|
|
|
|
{node.tooltip ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
{loadingNodeIds?.includes(node.id) ? (
|
|
<Loader2
|
|
className={cn('size-3.5 animate-spin', {
|
|
'text-white': isSelected,
|
|
})}
|
|
/>
|
|
) : (
|
|
<IconComponent
|
|
{...(isSelected
|
|
? { 'data-selected': true }
|
|
: {})}
|
|
{...iconProps}
|
|
/>
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
align="center"
|
|
className="max-w-[400px]"
|
|
>
|
|
{node.tooltip}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : node.empty ? null : loadingNodeIds?.includes(
|
|
node.id
|
|
) ? (
|
|
<Loader2
|
|
className={cn('size-3.5 animate-spin', {
|
|
// 'text-white': isSelected,
|
|
})}
|
|
/>
|
|
) : (
|
|
<IconComponent
|
|
{...(isSelected ? { 'data-selected': true } : {})}
|
|
{...iconProps}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<span
|
|
{...node.labelProps}
|
|
className={cn(
|
|
'text-xs truncate min-w-0 flex-1 w-0',
|
|
isSelected && 'font-medium text-primary text-white',
|
|
node.labelProps?.className
|
|
)}
|
|
{...(isSelected ? { 'data-selected': true } : {})}
|
|
>
|
|
{node.empty ? '' : node.name}
|
|
</span>
|
|
{renderActionsComponent && renderActionsComponent(node)}
|
|
{isHovered && renderHoverComponent
|
|
? renderHoverComponent(node)
|
|
: null}
|
|
</div>
|
|
|
|
<AnimatePresence initial={false}>
|
|
{isExpanded && children && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{
|
|
height: 'auto',
|
|
opacity: 1,
|
|
transition: {
|
|
height: {
|
|
duration: Math.min(
|
|
0.3 + children.length * 0.018,
|
|
0.7
|
|
),
|
|
ease: 'easeInOut',
|
|
},
|
|
opacity: {
|
|
duration: Math.min(
|
|
0.2 + children.length * 0.012,
|
|
0.4
|
|
),
|
|
ease: 'easeInOut',
|
|
},
|
|
},
|
|
}}
|
|
exit={{
|
|
height: 0,
|
|
opacity: 0,
|
|
transition: {
|
|
height: {
|
|
duration: Math.min(
|
|
0.2 + children.length * 0.01,
|
|
0.45
|
|
),
|
|
ease: 'easeInOut',
|
|
},
|
|
opacity: {
|
|
duration: 0.1,
|
|
ease: 'easeOut',
|
|
},
|
|
},
|
|
}}
|
|
style={{ overflow: 'hidden' }}
|
|
>
|
|
{children.map((child) => (
|
|
<TreeNode
|
|
key={child.id}
|
|
node={child}
|
|
level={level + 1}
|
|
expanded={expanded}
|
|
loading={loading}
|
|
loadedChildren={loadedChildren}
|
|
hasMoreChildren={hasMoreChildren}
|
|
onToggle={onToggle}
|
|
onNodeClick={onNodeClick}
|
|
defaultIcon={DefaultIcon}
|
|
defaultFolderIcon={DefaultFolderIcon}
|
|
defaultIconProps={defaultIconProps}
|
|
defaultFolderIconProps={defaultFolderIconProps}
|
|
selectable={selectable}
|
|
selectedId={selectedId}
|
|
onSelect={onSelect}
|
|
className="mt-0.5"
|
|
renderHoverComponent={renderHoverComponent}
|
|
renderActionsComponent={renderActionsComponent}
|
|
loadingNodeIds={loadingNodeIds}
|
|
/>
|
|
))}
|
|
{isLoading ? (
|
|
<TreeItemSkeleton
|
|
style={{
|
|
paddingLeft: `${level + 2 * 16 + 8}px`,
|
|
}}
|
|
/>
|
|
) : null}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function findNodeById<
|
|
Type extends string,
|
|
Context extends Record<Type, unknown>,
|
|
>(
|
|
nodes: TreeNode<Type, Context>[],
|
|
id: string,
|
|
initialPath: TreeNode<Type, Context>[] = []
|
|
): { node: TreeNode<Type, Context> | null; path: TreeNode<Type, Context>[] } {
|
|
const path: TreeNode<Type, Context>[] = [...initialPath];
|
|
for (const node of nodes) {
|
|
if (node.id === id) return { node, path };
|
|
if (node.children) {
|
|
const found = findNodeById(node.children, id, [...path, node]);
|
|
if (found.node) {
|
|
return found;
|
|
}
|
|
}
|
|
}
|
|
return { node: null, path };
|
|
}
|