shadcn-ui / ui

Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source.
https://ui.shadcn.com
MIT License
67.19k stars 3.93k forks source link

Feature Request: Add a File Tree component #355

Closed UmbrellaCrow612 closed 1 month ago

UmbrellaCrow612 commented 1 year ago

Description

I noticed that the shadcn UI library doesn't currently have a File Tree component, and I think it would be a great addition to the library. A File Tree component would allow users to display and navigate file structures in their web applications, similar to the functionality provided by many integrated development environments (IDEs).

Proposed API

The File Tree component should allow users to pass in a hierarchical data structure representing the files and folders to be displayed. It should support expanding and collapsing folders, selecting files, and navigating through the tree using the keyboard. It should also allow users to customize the appearance of the component using props.

Example Usage / Could be different

<FileTree
  data={treeData}
  onSelect={handleSelectFile}
  onExpand={handleExpandFolder}
  onCollapse={handleCollapseFolder}
  ...
/>

Additional Context

A File Tree component would be a valuable addition to the shadcn UI library, providing users with an easy-to-use interface for displaying and navigating file structures in their web applications.

bytechase commented 1 year ago

Here is something hacked together using some inspiration with the Accordion component. I mainly needed it just for a folder tree directory so its very basic. Unfortunately still needs some work, but its a start. Hope it helps!

Data Structure

const data = [
  { id: "1", name: "Unread" },
  { id: "2", name: "Threads" },
  {
    id: "3",
    name: "Chat Rooms",
    children: [
      { id: "c1", name: "General" },
      { id: "c2", name: "Random" },
      { id: "c3", name: "Open Source Projects" },
    ],
  },
  {
    id: "4",
    name: "Direct Messages",
    children: [
      {
        id: "d1",
        name: "Alice",
        children: [
          { id: "d1", name: "Alice2" },
          { id: "d2", name: "Bob2" },
          { id: "d3", name: "Charlie2" },
        ],
      },
      { id: "d2", name: "Bob" },
      { id: "d3", name: "Charlie" },
    ],
  },
];
import React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { cn } from "@/lib/utils";
import { ChevronRight, Folder, FolderPlus } from "lucide-react";

function Tree({ data }: { data: any[] | any }) {
  return (
    <ul role="list" className="space-y-1">
      {data instanceof Array ? (
        data.map((item) => (
          <li key={item.id}>
              {item.children ? (
               <a href="#">
                <AccordionPrimitive.Root type="single" collapsible>
                  <AccordionPrimitive.Item value="item-1">
                    <AccordionTrigger>
                      <FolderPlus
                        className="h-6 w-6 shrink-0 mr-5"
                        aria-hidden="true"
                      />
                      {item.name}
                    </AccordionTrigger>
                    <AccordionContent className="pl-4">
                      <Tree data={item.children ? item.children : item.name} />
                    </AccordionContent>
                  </AccordionPrimitive.Item>
                </AccordionPrimitive.Root>
              </a>
              ) : (
                <Leaf name={item.name} />
              )}

          </li>
        ))
      ) : (
        <li>
          <Leaf name={data.name} />
        </li>
      )}
    </ul>
  );
}

export default Tree;

function Leaf({ name }: { name: string }) {
  return (
    <a href="#" className={"flex flex-1 items-center py-4 font-medium"}>
      <ChevronRight className="h-4 w-4 shrink-0 opacity-0" />
      <Folder className="h-6 w-6 shrink-0 mr-5" aria-hidden="true" />
      {name}
    </a>
  );
}
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 py-4 font-medium transition-all first:[&[data-state=open]>svg]:rotate-90",
        className
      )}
      {...props}
    >
      <ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200" />
      {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-4 pt-0">{children}</div>
  </AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
mehmetkursataydin commented 12 months ago

Please add this feature soon <3

WangLarry commented 12 months ago

inspired by @bytechase , my simple tree component:

add features:
1. initial select item
2. expand initial-selected folder
3. highlight item
4. hover item
5. scroll window
6. custom icon

截屏2023-09-03 11 12 29截屏2023-09-03 11 23 01

"use client"

import * as React from "react"
import { Shell } from "@acme/components/shells/shell"
import { Tree } from "@acme/components/ui/tree"
import { Workflow, Folder, Layout } from "lucide-react";

const data = [
  { id: "1", name: "Unread" },
  { id: "2", name: "Threads" },
  {
    id: "3",
    name: "Chat Rooms",
    children: [
      { id: "c1", name: "General" },
      { id: "c2", name: "Random" },
      { id: "c3", name: "Open Source Projects" },
    ],
  },
  {
    id: "4",
    name: "Direct Messages",
    children: [
      {
        id: "d1",
        name: "Alice",
        children: [
          { id: "d11", name: "Alice2", icon: Layout },
          { id: "d12", name: "Bob2" },
          { id: "d13", name: "Charlie2" },
        ],
      },
      { id: "d2", name: "Bob", icon: Layout },
      { id: "d3", name: "Charlie" },
    ],
  },
  {
    id: "5",
    name: "Direct Messages",
    children: [
      {
        id: "e1",
        name: "Alice",
        children: [
          { id: "e11", name: "Alice2" },
          { id: "e12", name: "Bob2" },
          { id: "e13", name: "Charlie2" },
        ],
      },
      { id: "e2", name: "Bob" },
      { id: "e3", name: "Charlie" },
    ],
  },
  {
    id: "6",
    name: "Direct Messages",
    children: [
      {
        id: "f1",
        name: "Alice",
        children: [
          { id: "f11", name: "Alice2" },
          { id: "f12", name: "Bob2" },
          { id: "f13", name: "Charlie2" },
        ],
      },
      { id: "f2", name: "Bob" },
      { id: "f3", name: "Charlie" },
    ],
  },
];

export default function IndexPage() {
  const [content, setContent] = React.useState("Admin Page")
  return (
    <Shell className="gap-12 min-h-screen">
      <div className="flex min-h-full space-x-2">
        <Tree
          data={data}
          className="flex-shrink-0 w-[200px] h-[460px] border-[1px]"
          initialSlelectedItemId="f12"
          onSelectChange={(item) => setContent(item?.name ?? "")}
          folderIcon={Folder}
          itemIcon={Workflow}
        />
        <div className="flex-1">{content}</div>
      </div>
    </Shell>
  )
}
"use client";

import React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ScrollArea } from "@acme/components/ui/scroll-area"
import { cn } from "@acme/components/lib/utils";
import { ChevronRight, type LucideIcon } from "lucide-react";
import useResizeObserver from "use-resize-observer";

interface TreeDataItem {
  id: string;
  name: string;
  icon?: LucideIcon,
  children?: TreeDataItem[];
}

type TreeProps =
  React.HTMLAttributes<HTMLDivElement> &
  {
    data: TreeDataItem[] | TreeDataItem,
    initialSlelectedItemId?: string,
    onSelectChange?: (item: TreeDataItem | undefined) => void,
    expandAll?: boolean,
    folderIcon?: LucideIcon,
    itemIcon?: LucideIcon
  }

const Tree = React.forwardRef<
  HTMLDivElement,
  TreeProps
>(({
  data, initialSlelectedItemId, onSelectChange, expandAll,
  folderIcon,
  itemIcon,
  className, ...props
}, ref) => {
  const [selectedItemId, setSelectedItemId] = React.useState<string | undefined>(initialSlelectedItemId)

  const handleSelectChange = React.useCallback((item: TreeDataItem | undefined) => {
    setSelectedItemId(item?.id);
    if (onSelectChange) {
      onSelectChange(item)
    }
  }, [onSelectChange]);

  const expandedItemIds = React.useMemo(() => {
    if (!initialSlelectedItemId) {
      return [] as string[]
    }

    const ids: string[] = []

    function walkTreeItems(items: TreeDataItem[] | TreeDataItem, targetId: string) {
      if (items instanceof Array) {
        // eslint-disable-next-line @typescript-eslint/prefer-for-of
        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, initialSlelectedItemId)
    return ids;
  }, [data, initialSlelectedItemId])

  const { ref: refRoot, width, height } = useResizeObserver();

  return (
    <div ref={refRoot} className={cn("overflow-hidden", className)}>
      <ScrollArea style={{ width, height }}>
        <div className="relative p-2">
          <TreeItem
            data={data}
            ref={ref}
            selectedItemId={selectedItemId}
            handleSelectChange={handleSelectChange}
            expandedItemIds={expandedItemIds}
            FolderIcon={folderIcon}
            ItemIcon={itemIcon}
            {...props}
          />
        </div>
      </ScrollArea>
    </div>
  )
})

type TreeItemProps =
  TreeProps &
  {
    selectedItemId?: string,
    handleSelectChange: (item: TreeDataItem | undefined) => void,
    expandedItemIds: string[],
    FolderIcon?: LucideIcon,
    ItemIcon?: LucideIcon
  }

const TreeItem = React.forwardRef<
  HTMLDivElement,
  TreeItemProps
>(({ className, data, selectedItemId, handleSelectChange, expandedItemIds, FolderIcon, ItemIcon, ...props }, ref) => {
  return (
    <div ref={ref} role="tree" className={className} {...props}><ul>
      {data instanceof Array ? (
        data.map((item) => (
          <li key={item.id}>
            {item.children ? (
              <AccordionPrimitive.Root type="multiple" defaultValue={expandedItemIds}>
                <AccordionPrimitive.Item value={item.id}>
                  <AccordionTrigger
                    className={cn(
                      "px-2 hover:before:opacity-100 before:absolute before:left-0 before:w-full before:opacity-0 before:bg-muted/80 before:h-[1.75rem] before:-z-10",
                      selectedItemId === item.id && "before:opacity-100 before:bg-accent text-accent-foreground before:border-l-2 before:border-l-accent-foreground/50 dark:before:border-0"
                    )}
                    onClick={() => handleSelectChange(item)}
                  >
                    {item.icon &&
                      <item.icon
                        className="h-4 w-4 shrink-0 mr-2 text-accent-foreground/50"
                        aria-hidden="true"
                      />
                    }
                    {!item.icon && FolderIcon &&
                      <FolderIcon
                        className="h-4 w-4 shrink-0 mr-2 text-accent-foreground/50"
                        aria-hidden="true"
                      />
                    }
                    <span className="text-sm truncate">{item.name}</span>
                  </AccordionTrigger>
                  <AccordionContent className="pl-6">
                    <TreeItem
                      data={item.children ? item.children : item}
                      selectedItemId={selectedItemId}
                      handleSelectChange={handleSelectChange}
                      expandedItemIds={expandedItemIds}
                      FolderIcon={FolderIcon}
                      ItemIcon={ItemIcon}
                    />
                  </AccordionContent>
                </AccordionPrimitive.Item>
              </AccordionPrimitive.Root>
            ) : (
              <Leaf
                item={item}
                isSelected={selectedItemId === item.id}
                onClick={() => handleSelectChange(item)}
                Icon={ItemIcon}
              />
            )}
          </li>
        ))
      ) : (
        <li>
          <Leaf
            item={data}
            isSelected={selectedItemId === data.id}
            onClick={() => handleSelectChange(data)}
            Icon={ItemIcon}
          />
        </li>
      )}
    </ul></div>
  );
})

const Leaf = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement> & {
    item: TreeDataItem, isSelected?: boolean,
    Icon?: LucideIcon
  }
>(({ className, item, isSelected, Icon, ...props }, ref) => {
  return (
    <div
      ref={ref}
      className={cn(
        "flex items-center py-2 px-2 cursor-pointer \
        hover:before:opacity-100 before:absolute before:left-0 before:right-1 before:w-full before:opacity-0 before:bg-muted/80 before:h-[1.75rem] before:-z-10",
        className,
        isSelected && "before:opacity-100 before:bg-accent text-accent-foreground before:border-l-2 before:border-l-accent-foreground/50 dark:before:border-0"
      )}
      {...props}
    >
      {item.icon && <item.icon className="h-4 w-4 shrink-0 mr-2 text-accent-foreground/50" aria-hidden="true" />}
      {!item.icon && Icon && <Icon className="h-4 w-4 shrink-0 mr-2 text-accent-foreground/50" aria-hidden="true" />}
      <span className="flex-grow text-sm truncate">{item.name}</span>
    </div>
  );
})

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 last:[&[data-state=open]>svg]:rotate-90",
        className
      )}
      {...props}
    >
      {children}
      <ChevronRight className="h-4 w-4 shrink-0 transition-transform duration-200 text-accent-foreground/50 ml-auto" />
    </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;

export { Tree, type TreeDataItem }
scroll-area.tsx,   support 'truncate'( text ellipsis): 

- <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
+ <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">

refer: [https://github.com/radix-ui/primitives/issues/926#issuecomment-1266790070](https://github.com/radix-ui/primitives/issues/926#issuecomment-1266790070)
mehmetkursataydin commented 11 months ago

Thank you @WangLarry for not changing the api and sharing your Tree, I am now using yours ♥

CohleM commented 10 months ago

I'm clueless on how to use the code from @WangLarry because of the imports. I can't figure out where these imports are coming from. Does anyone have the code on github?

matzeso commented 10 months ago

@CohleM anything in the imports starting with @acme/components refers to the path where you have stored your shadcn ui components + the lib/utils file. You just replace it with your own path to those components.

You can probably find the path in your components.json file that got created if you installed the components using the CLI tool. See https://ui.shadcn.com/docs/components-json

njacob1001 commented 8 months ago

Awesome work @WangLarry I'm using your component and looks very nice! love it

yungrari commented 7 months ago

I have designed and built a tree component in the shadcn/ui ecosystem for the EBRAINS project. It is more conceptual than practical for use irl but it may serve as inspiration for someone. Here are the repo and the demo.

ryanuo commented 2 months ago

There is still no support at the moment

ryanuo commented 2 months ago

There is still no support at the moment

@WangLarry Easy to use image

shadcn commented 1 month ago

This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you.

pavelbinar commented 1 month ago

Let’s keep this open. I think this is quite a useful component for many use cases. At the very least, this can serve as an aggregator of ideas and references for other implementations.