512 lines
13 KiB
TypeScript
512 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import React, {
|
|
createContext,
|
|
forwardRef,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
} from "react";
|
|
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
|
import { FileIcon, FolderIcon, FolderOpenIcon } from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
|
|
type TreeViewElement = {
|
|
id: string;
|
|
name: string;
|
|
type?: "file" | "folder";
|
|
isSelectable?: boolean;
|
|
children?: TreeViewElement[];
|
|
};
|
|
|
|
type TreeSortMode =
|
|
| "default"
|
|
| "none"
|
|
| ((a: TreeViewElement, b: TreeViewElement) => number);
|
|
|
|
type TreeContextProps = {
|
|
selectedId: string | undefined;
|
|
expandedItems: string[] | undefined;
|
|
indicator: boolean;
|
|
handleExpand: (id: string) => void;
|
|
selectItem: (id: string) => void;
|
|
setExpandedItems?: React.Dispatch<React.SetStateAction<string[] | undefined>>;
|
|
openIcon?: React.ReactNode;
|
|
closeIcon?: React.ReactNode;
|
|
direction: "rtl" | "ltr";
|
|
onExpandItem?: (id: string) => void;
|
|
};
|
|
|
|
const TreeContext = createContext<TreeContextProps | null>(null);
|
|
|
|
const useTree = () => {
|
|
const context = useContext(TreeContext);
|
|
if (!context) {
|
|
throw new Error("useTree must be used within a TreeProvider");
|
|
}
|
|
return context;
|
|
};
|
|
|
|
type Direction = "rtl" | "ltr" | undefined;
|
|
|
|
const isFolderElement = (element: TreeViewElement) => {
|
|
if (element.type) {
|
|
return element.type === "folder";
|
|
}
|
|
|
|
return Array.isArray(element.children);
|
|
};
|
|
|
|
const mergeExpandedItems = (
|
|
currentItems: string[] | undefined,
|
|
nextItems: string[],
|
|
) => [...new Set([...(currentItems ?? []), ...nextItems])];
|
|
|
|
const treeCollator = new Intl.Collator("en", {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
});
|
|
|
|
const defaultTreeComparator = (a: TreeViewElement, b: TreeViewElement) => {
|
|
const aIsFolder = isFolderElement(a);
|
|
const bIsFolder = isFolderElement(b);
|
|
|
|
if (aIsFolder !== bIsFolder) {
|
|
return aIsFolder ? -1 : 1;
|
|
}
|
|
|
|
return treeCollator.compare(a.name, b.name);
|
|
};
|
|
|
|
const getTreeComparator = (sort: TreeSortMode) => {
|
|
if (sort === "none") {
|
|
return undefined;
|
|
}
|
|
|
|
if (sort === "default") {
|
|
return defaultTreeComparator;
|
|
}
|
|
|
|
return sort;
|
|
};
|
|
|
|
const sortTreeElements = (
|
|
elements: TreeViewElement[],
|
|
sort: TreeSortMode,
|
|
): TreeViewElement[] => {
|
|
const comparator = getTreeComparator(sort);
|
|
|
|
const nextElements = elements.map((element) => {
|
|
if (!Array.isArray(element.children)) {
|
|
return element;
|
|
}
|
|
|
|
return {
|
|
...element,
|
|
children: sortTreeElements(element.children, sort),
|
|
};
|
|
});
|
|
|
|
if (!comparator) {
|
|
return nextElements;
|
|
}
|
|
|
|
return [...nextElements].sort(comparator);
|
|
};
|
|
|
|
const renderTreeElements = (
|
|
elements: TreeViewElement[],
|
|
sort: TreeSortMode,
|
|
): React.ReactNode =>
|
|
sortTreeElements(elements, sort).map((element) => {
|
|
if (isFolderElement(element)) {
|
|
return (
|
|
<Folder
|
|
key={element.id}
|
|
value={element.id}
|
|
element={element.name}
|
|
isSelectable={element.isSelectable}
|
|
>
|
|
{Array.isArray(element.children)
|
|
? renderTreeElements(element.children, sort)
|
|
: null}
|
|
</Folder>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<File
|
|
key={element.id}
|
|
value={element.id}
|
|
isSelectable={element.isSelectable}
|
|
>
|
|
<span>{element.name}</span>
|
|
</File>
|
|
);
|
|
});
|
|
|
|
type TreeViewProps = {
|
|
initialSelectedId?: string;
|
|
indicator?: boolean;
|
|
elements?: TreeViewElement[];
|
|
initialExpandedItems?: string[];
|
|
onExpandItem?: (id: string) => void;
|
|
openIcon?: React.ReactNode;
|
|
closeIcon?: React.ReactNode;
|
|
sort?: TreeSortMode;
|
|
} & Omit<
|
|
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Root>,
|
|
"defaultValue" | "onValueChange" | "type" | "value"
|
|
>;
|
|
|
|
const Tree = forwardRef<HTMLDivElement, TreeViewProps>(
|
|
(
|
|
{
|
|
className,
|
|
elements,
|
|
initialSelectedId,
|
|
initialExpandedItems,
|
|
onExpandItem,
|
|
children,
|
|
indicator = true,
|
|
openIcon,
|
|
closeIcon,
|
|
sort = "default",
|
|
dir,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
const [selectedId, setSelectedId] = useState<string | undefined>(
|
|
initialSelectedId,
|
|
);
|
|
const [expandedItems, setExpandedItems] = useState<string[] | undefined>(
|
|
initialExpandedItems,
|
|
);
|
|
|
|
const selectItem = useCallback((id: string) => {
|
|
setSelectedId(id);
|
|
}, []);
|
|
|
|
const handleExpand = useCallback(
|
|
(id: string) => {
|
|
setExpandedItems((prev) => {
|
|
if (prev?.includes(id)) {
|
|
return prev.filter((item) => item !== id);
|
|
}
|
|
return [...(prev ?? []), id];
|
|
});
|
|
onExpandItem?.(id);
|
|
},
|
|
[onExpandItem],
|
|
);
|
|
|
|
const expandSpecificTargetedElements = useCallback(
|
|
(elements?: TreeViewElement[], selectId?: string) => {
|
|
if (!elements || !selectId) return;
|
|
const findParent = (
|
|
currentElement: TreeViewElement,
|
|
currentPath: string[] = [],
|
|
) => {
|
|
const isSelectable = currentElement.isSelectable ?? true;
|
|
const newPath = [...currentPath, currentElement.id];
|
|
if (currentElement.id === selectId) {
|
|
if (isSelectable) {
|
|
setExpandedItems((prev) => mergeExpandedItems(prev, newPath));
|
|
} else {
|
|
if (newPath.includes(currentElement.id)) {
|
|
newPath.pop();
|
|
setExpandedItems((prev) => mergeExpandedItems(prev, newPath));
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (
|
|
Array.isArray(currentElement.children) &&
|
|
currentElement.children.length > 0
|
|
) {
|
|
currentElement.children.forEach((child) => {
|
|
findParent(child, newPath);
|
|
});
|
|
}
|
|
};
|
|
elements.forEach((element) => {
|
|
findParent(element);
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (initialSelectedId) {
|
|
expandSpecificTargetedElements(elements, initialSelectedId);
|
|
}
|
|
}, [initialSelectedId, elements, expandSpecificTargetedElements]);
|
|
|
|
const direction = dir === "rtl" ? "rtl" : "ltr";
|
|
const treeChildren =
|
|
children ?? (elements ? renderTreeElements(elements, sort) : null);
|
|
|
|
return (
|
|
<TreeContext.Provider
|
|
value={{
|
|
selectedId,
|
|
expandedItems,
|
|
handleExpand,
|
|
selectItem,
|
|
setExpandedItems,
|
|
indicator,
|
|
openIcon,
|
|
closeIcon,
|
|
direction,
|
|
onExpandItem,
|
|
}}
|
|
>
|
|
<div className={cn("size-full", className)}>
|
|
<ScrollArea
|
|
ref={ref}
|
|
className="relative h-full px-2"
|
|
dir={dir as Direction}
|
|
>
|
|
<AccordionPrimitive.Root
|
|
{...props}
|
|
type="multiple"
|
|
value={expandedItems}
|
|
className="flex flex-col gap-1"
|
|
dir={dir as Direction}
|
|
>
|
|
{treeChildren}
|
|
</AccordionPrimitive.Root>
|
|
</ScrollArea>
|
|
</div>
|
|
</TreeContext.Provider>
|
|
);
|
|
},
|
|
);
|
|
|
|
Tree.displayName = "Tree";
|
|
|
|
const TreeIndicator = forwardRef<
|
|
HTMLDivElement,
|
|
React.HTMLAttributes<HTMLDivElement>
|
|
>(({ className, ...props }, ref) => {
|
|
const { direction } = useTree();
|
|
|
|
return (
|
|
<div
|
|
dir={direction}
|
|
ref={ref}
|
|
className={cn(
|
|
"bg-muted absolute left-1.5 h-full w-px rounded-md py-3 duration-300 ease-in-out hover:bg-slate-300 rtl:right-1.5",
|
|
className,
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
});
|
|
|
|
TreeIndicator.displayName = "TreeIndicator";
|
|
|
|
type FolderProps = {
|
|
expandedItems?: string[];
|
|
element: string;
|
|
isSelectable?: boolean;
|
|
isSelect?: boolean;
|
|
} & React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>;
|
|
|
|
const Folder = forwardRef<
|
|
HTMLDivElement,
|
|
FolderProps & React.HTMLAttributes<HTMLDivElement>
|
|
>(
|
|
(
|
|
{
|
|
className,
|
|
element,
|
|
value,
|
|
isSelectable = true,
|
|
isSelect,
|
|
children,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
const {
|
|
direction,
|
|
handleExpand,
|
|
expandedItems,
|
|
indicator,
|
|
selectedId,
|
|
selectItem,
|
|
openIcon,
|
|
closeIcon,
|
|
} = useTree();
|
|
const isSelected = isSelect ?? selectedId === value;
|
|
|
|
return (
|
|
<AccordionPrimitive.Item
|
|
ref={ref}
|
|
{...props}
|
|
value={value}
|
|
className="relative h-full overflow-hidden"
|
|
>
|
|
<AccordionPrimitive.Trigger
|
|
className={cn(
|
|
`flex items-center gap-1 rounded-md text-sm`,
|
|
className,
|
|
{
|
|
"bg-muted rounded-md": isSelected && isSelectable,
|
|
"cursor-pointer": isSelectable,
|
|
"cursor-not-allowed opacity-50": !isSelectable,
|
|
},
|
|
)}
|
|
disabled={!isSelectable}
|
|
onClick={() => {
|
|
selectItem(value);
|
|
handleExpand(value);
|
|
}}
|
|
>
|
|
{expandedItems?.includes(value)
|
|
? (openIcon ?? <FolderOpenIcon className="size-4" />)
|
|
: (closeIcon ?? <FolderIcon className="size-4" />)}
|
|
<span>{element}</span>
|
|
</AccordionPrimitive.Trigger>
|
|
<AccordionPrimitive.Content className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down relative h-full overflow-hidden text-sm">
|
|
{element && indicator && <TreeIndicator aria-hidden="true" />}
|
|
<AccordionPrimitive.Root
|
|
dir={direction}
|
|
type="multiple"
|
|
className="ml-5 flex flex-col gap-1 py-1 rtl:mr-5"
|
|
value={expandedItems}
|
|
>
|
|
{children}
|
|
</AccordionPrimitive.Root>
|
|
</AccordionPrimitive.Content>
|
|
</AccordionPrimitive.Item>
|
|
);
|
|
},
|
|
);
|
|
|
|
Folder.displayName = "Folder";
|
|
|
|
const File = forwardRef<
|
|
HTMLButtonElement,
|
|
{
|
|
value: string;
|
|
handleSelect?: (id: string) => void;
|
|
isSelectable?: boolean;
|
|
isSelect?: boolean;
|
|
fileIcon?: React.ReactNode;
|
|
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
|
>(
|
|
(
|
|
{
|
|
value,
|
|
className,
|
|
handleSelect,
|
|
onClick,
|
|
isSelectable = true,
|
|
isSelect,
|
|
fileIcon,
|
|
children,
|
|
...props
|
|
},
|
|
ref,
|
|
) => {
|
|
const { direction, selectedId, selectItem } = useTree();
|
|
const isSelected = isSelect ?? selectedId === value;
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
type="button"
|
|
disabled={!isSelectable}
|
|
className={cn(
|
|
"flex w-fit items-center gap-1 rounded-md pr-1 text-sm duration-200 ease-in-out rtl:pr-0 rtl:pl-1",
|
|
{
|
|
"bg-muted": isSelected && isSelectable,
|
|
},
|
|
isSelectable ? "cursor-pointer" : "cursor-not-allowed opacity-50",
|
|
direction === "rtl" ? "rtl" : "ltr",
|
|
className,
|
|
)}
|
|
onClick={(event) => {
|
|
selectItem(value);
|
|
handleSelect?.(value);
|
|
onClick?.(event);
|
|
}}
|
|
{...props}
|
|
>
|
|
{fileIcon ?? <FileIcon className="size-4" />}
|
|
{children}
|
|
</button>
|
|
);
|
|
},
|
|
);
|
|
|
|
File.displayName = "File";
|
|
|
|
const CollapseButton = forwardRef<
|
|
HTMLButtonElement,
|
|
{
|
|
elements: TreeViewElement[];
|
|
expandAll?: boolean;
|
|
} & React.HTMLAttributes<HTMLButtonElement>
|
|
>(({ className, elements, expandAll = false, children, ...props }, ref) => {
|
|
const { expandedItems, setExpandedItems } = useTree();
|
|
|
|
const expendAllTree = useCallback((elements: TreeViewElement[]) => {
|
|
const expandedElementIds: string[] = [];
|
|
|
|
const expandTree = (element: TreeViewElement) => {
|
|
const isSelectable = element.isSelectable ?? true;
|
|
if (isSelectable && element.children && element.children.length > 0) {
|
|
expandedElementIds.push(element.id);
|
|
for (const child of element.children) {
|
|
expandTree(child);
|
|
}
|
|
}
|
|
};
|
|
|
|
for (const element of elements) {
|
|
expandTree(element);
|
|
}
|
|
|
|
return [...new Set(expandedElementIds)];
|
|
}, []);
|
|
|
|
const closeAll = useCallback(() => {
|
|
setExpandedItems?.([]);
|
|
}, [setExpandedItems]);
|
|
|
|
useEffect(() => {
|
|
if (expandAll) {
|
|
setExpandedItems?.(expendAllTree(elements));
|
|
}
|
|
}, [expandAll, elements, expendAllTree, setExpandedItems]);
|
|
|
|
return (
|
|
<Button
|
|
variant={"ghost"}
|
|
className={cn("absolute right-2 bottom-1 h-8 w-fit p-1", className)}
|
|
onClick={
|
|
expandedItems && expandedItems.length > 0
|
|
? closeAll
|
|
: () => setExpandedItems?.(expendAllTree(elements))
|
|
}
|
|
ref={ref}
|
|
{...props}
|
|
>
|
|
{children}
|
|
<span className="sr-only">Toggle</span>
|
|
</Button>
|
|
);
|
|
});
|
|
|
|
CollapseButton.displayName = "CollapseButton";
|
|
|
|
export { CollapseButton, File, Folder, Tree, type TreeViewElement };
|
|
export type { TreeSortMode };
|