File Tree Editor
Data Display

File Tree Editor

An editable file tree with inline create and rename, duplicate-name validation, layout-animated add and remove, folders-first sorting, and the full ARIA tree keyboard pattern plus F2 and Delete.

Install

npx shadcn@latest add @paragon/file-tree-editor

Also installs: tooltip

file-tree-editor.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  ChevronRight,
  ChevronsDownUp,
  File,
  FilePlus,
  Folder,
  FolderOpen,
  FolderPlus,
  Pencil,
  Trash2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";

export interface FileTreeNode {
  id: string;
  name: string;
  type: "file" | "folder";
  children?: FileTreeNode[];
}

export interface FileTreeEditorProps
  extends Omit<
    React.ComponentProps<"div">,
    "onChange" | "onSelect" | "defaultValue"
  > {
  /** Initial tree. The editor owns state after mount. */
  defaultNodes?: FileTreeNode[];
  onChange?: (nodes: FileTreeNode[]) => void;
  /** Fires when a row is activated (click, Enter, Space). */
  onSelect?: (node: FileTreeNode) => void;
  /** Auto-expand folders to this depth on first render. `1` = top level. */
  defaultExpandedDepth?: number;
  /** Heading above the tree. */
  label?: string;
  /** Vertical indentation rails linking each level. */
  showGuides?: boolean;
  /** Disables expand/add/remove motion. */
  static?: boolean;
}

interface FlatRow {
  node: FileTreeNode;
  depth: number;
  parentId: string | null;
}

interface EditingState {
  id: string;
  isNew: boolean;
  originalName: string;
}

// ---------------------------------------------------------------------------
// Pure tree helpers
// ---------------------------------------------------------------------------

function sortLevel(nodes: FileTreeNode[]): FileTreeNode[] {
  return [...nodes].sort((a, b) =>
    a.type === b.type
      ? a.name.localeCompare(b.name, "en", { sensitivity: "base" })
      : a.type === "folder"
        ? -1
        : 1,
  );
}

function sortTree(nodes: FileTreeNode[]): FileTreeNode[] {
  return sortLevel(nodes).map((n) =>
    n.children ? { ...n, children: sortTree(n.children) } : n,
  );
}

/** Replace the child list of `parentId` (null = root) via `fn`. */
function mapLevel(
  nodes: FileTreeNode[],
  parentId: string | null,
  fn: (level: FileTreeNode[]) => FileTreeNode[],
): FileTreeNode[] {
  if (parentId === null) return fn(nodes);
  return nodes.map((n) => {
    if (n.type !== "folder") return n;
    if (n.id === parentId) return { ...n, children: fn(n.children ?? []) };
    return n.children
      ? { ...n, children: mapLevel(n.children, parentId, fn) }
      : n;
  });
}

function findNode(nodes: FileTreeNode[], id: string): FileTreeNode | null {
  for (const n of nodes) {
    if (n.id === id) return n;
    if (n.children) {
      const hit = findNode(n.children, id);
      if (hit) return hit;
    }
  }
  return null;
}

function findParentId(
  nodes: FileTreeNode[],
  id: string,
  parent: string | null = null,
): string | null | undefined {
  for (const n of nodes) {
    if (n.id === id) return parent;
    if (n.children) {
      const hit = findParentId(n.children, id, n.id);
      if (hit !== undefined) return hit;
    }
  }
  return undefined;
}

function countNodes(node: FileTreeNode): number {
  return 1 + (node.children?.reduce((sum, c) => sum + countNodes(c), 0) ?? 0);
}

// ---------------------------------------------------------------------------

/**
 * An editable file tree — create, rename, and delete files and folders
 * inline. Rows follow the full ARIA tree keyboard pattern (arrows, Home/
 * End, typeahead) plus F2 to rename and Delete to remove; the inline
 * editor blocks empty and duplicate sibling names while you type. Folders
 * animate through grid-rows, added and removed rows enter and exit with
 * layout so siblings glide, levels keep folders-first alphabetical order,
 * and every mutation is announced to screen readers.
 */
export function FileTreeEditor({
  defaultNodes = [],
  onChange,
  onSelect,
  defaultExpandedDepth = 1,
  label = "Files",
  showGuides = true,
  static: isStatic = false,
  className,
  ...props
}: FileTreeEditorProps) {
  const uid = React.useId();
  const counter = React.useRef(0);
  const reducedMotion = useReducedMotion() ?? false;
  const noMotion = reducedMotion || isStatic;

  const [nodes, setNodes] = React.useState<FileTreeNode[]>(() =>
    sortTree(defaultNodes),
  );
  const [expanded, setExpanded] = React.useState<Set<string>>(() => {
    const ids = new Set<string>();
    if (defaultExpandedDepth > 0) {
      const walk = (list: FileTreeNode[], depth: number) => {
        for (const n of list) {
          if (n.type === "folder") {
            if (depth <= defaultExpandedDepth) ids.add(n.id);
            walk(n.children ?? [], depth + 1);
          }
        }
      };
      walk(defaultNodes, 1);
    }
    return ids;
  });
  const [selected, setSelected] = React.useState<string | null>(null);
  const [focused, setFocused] = React.useState<string | null>(null);
  const [editing, setEditing] = React.useState<EditingState | null>(null);
  const [draft, setDraft] = React.useState("");
  const [announce, setAnnounce] = React.useState("");
  const rowRefs = React.useRef(new Map<string, HTMLDivElement>());

  const commit = (next: FileTreeNode[]) => {
    setNodes(next);
    onChange?.(next);
  };

  // Visible rows in document order — the keyboard navigation space.
  const flat = React.useMemo<FlatRow[]>(() => {
    const list: FlatRow[] = [];
    const walk = (
      items: FileTreeNode[],
      depth: number,
      parentId: string | null,
    ) => {
      for (const node of items) {
        list.push({ node, depth, parentId });
        if (node.type === "folder" && expanded.has(node.id)) {
          walk(node.children ?? [], depth + 1, node.id);
        }
      }
    };
    walk(nodes, 1, null);
    return list;
  }, [nodes, expanded]);

  const focusTarget = focused ?? selected ?? flat[0]?.node.id ?? null;

  const focusId = (id: string | undefined | null) => {
    if (!id) return;
    setFocused(id);
    rowRefs.current.get(id)?.focus();
  };

  const toggle = (id: string) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  // -- Mutations -------------------------------------------------------------

  const siblingTaken = (parentId: string | null, name: string, self: string) => {
    const level =
      parentId === null ? nodes : (findNode(nodes, parentId)?.children ?? []);
    const target = name.trim().toLowerCase();
    return level.some(
      (n) => n.id !== self && n.name.trim().toLowerCase() === target,
    );
  };

  const startCreate = (type: "file" | "folder") => {
    // Create inside the selected folder, next to a selected file, else root.
    let parentId: string | null = null;
    if (selected) {
      const node = findNode(nodes, selected);
      if (node?.type === "folder") parentId = node.id;
      else if (node) parentId = findParentId(nodes, node.id) ?? null;
    }
    const id = `${uid}-n${counter.current++}`;
    const fresh: FileTreeNode =
      type === "folder" ? { id, name: "", type, children: [] } : { id, name: "", type };
    if (parentId) {
      setExpanded((prev) => new Set(prev).add(parentId));
    }
    setNodes((prev) => mapLevel(prev, parentId, (level) => [...level, fresh]));
    setEditing({ id, isNew: true, originalName: "" });
    setDraft("");
    setSelected(id);
    setFocused(id);
  };

  const startRename = (node: FileTreeNode) => {
    setEditing({ id: node.id, isNew: false, originalName: node.name });
    setDraft(node.name);
    setSelected(node.id);
    setFocused(node.id);
  };

  const cancelEdit = () => {
    if (!editing) return;
    const { id, isNew } = editing;
    setEditing(null);
    if (isNew) {
      setNodes((prev) => {
        const parentId = findParentId(prev, id) ?? null;
        return mapLevel(prev, parentId, (level) =>
          level.filter((n) => n.id !== id),
        );
      });
      setSelected(null);
    } else {
      focusId(id);
    }
  };

  /**
   * `blurring` — an invalid name blocks Enter (stay editing) but a blur
   * can't hold focus hostage, so it falls back to cancel.
   */
  const commitEdit = (blurring = false) => {
    if (!editing) return;
    const { id, isNew, originalName } = editing;
    const name = draft.trim();
    const parentId = findParentId(nodes, id) ?? null;
    if (!name || siblingTaken(parentId, name, id)) {
      if (blurring) cancelEdit();
      return;
    }
    setEditing(null);
    const next = mapLevel(nodes, parentId, (level) =>
      sortLevel(level.map((n) => (n.id === id ? { ...n, name } : n))),
    );
    commit(next);
    const node = findNode(next, id);
    setAnnounce(
      isNew
        ? `Created ${node?.type ?? "file"} ${name}`
        : `Renamed ${originalName} to ${name}`,
    );
    focusId(id);
    onSelect?.(node ?? { id, name, type: "file" });
  };

  const removeNode = (node: FileTreeNode) => {
    const index = flat.findIndex((row) => row.node.id === node.id);
    const parentId = findParentId(nodes, node.id) ?? null;
    const next = mapLevel(nodes, parentId, (level) =>
      level.filter((n) => n.id !== node.id),
    );
    commit(next);
    const contents = countNodes(node) - 1;
    setAnnounce(
      node.type === "folder" && contents > 0
        ? `Deleted folder ${node.name} and ${contents} item${contents === 1 ? "" : "s"}`
        : `Deleted ${node.type} ${node.name}`,
    );
    if (selected === node.id) setSelected(parentId);
    const fallback = flat[index - 1]?.node.id ?? flat[index + 1]?.node.id;
    focusId(fallback === node.id ? undefined : fallback);
  };

  const activate = (node: FileTreeNode) => {
    setSelected(node.id);
    setFocused(node.id);
    if (node.type === "folder") toggle(node.id);
    onSelect?.(node);
  };

  // -- Keyboard --------------------------------------------------------------

  const handleKeyDown = (event: React.KeyboardEvent, node: FileTreeNode) => {
    if (editing) return;
    const ids = flat.map((row) => row.node.id);
    const i = ids.indexOf(node.id);
    const isFolder = node.type === "folder";
    switch (event.key) {
      case "ArrowDown":
        event.preventDefault();
        focusId(ids[i + 1]);
        break;
      case "ArrowUp":
        event.preventDefault();
        focusId(ids[i - 1]);
        break;
      case "ArrowRight":
        event.preventDefault();
        if (!isFolder) break;
        if (!expanded.has(node.id)) toggle(node.id);
        else focusId(node.children?.[0]?.id);
        break;
      case "ArrowLeft":
        event.preventDefault();
        if (isFolder && expanded.has(node.id)) toggle(node.id);
        else focusId(flat[i]?.parentId);
        break;
      case "Home":
        event.preventDefault();
        focusId(ids[0]);
        break;
      case "End":
        event.preventDefault();
        focusId(ids[ids.length - 1]);
        break;
      case "Enter":
      case " ":
        event.preventDefault();
        activate(node);
        break;
      case "F2":
        event.preventDefault();
        startRename(node);
        break;
      case "Delete":
        event.preventDefault();
        removeNode(node);
        break;
      default:
        if (
          event.key.length === 1 &&
          !event.ctrlKey &&
          !event.metaKey &&
          !event.altKey &&
          /\S/.test(event.key)
        ) {
          const char = event.key.toLowerCase();
          const reordered = [...flat.slice(i + 1), ...flat.slice(0, i + 1)];
          const match = reordered.find((row) =>
            row.node.name.toLowerCase().startsWith(char),
          );
          if (match) {
            event.preventDefault();
            focusId(match.node.id);
          }
        }
        break;
    }
  };

  // -- Rendering -------------------------------------------------------------

  const renderLevel = (
    level: FileTreeNode[],
    depth: number,
  ): React.ReactNode => (
    <AnimatePresence initial={false} mode="popLayout">
      {level.map((node) => {
        const isFolder = node.type === "folder";
        const isExpanded = expanded.has(node.id);
        const isSelected = selected === node.id;
        const isEditing = editing?.id === node.id;
        const parentId = findParentId(nodes, node.id) ?? null;
        const invalid =
          isEditing &&
          (draft.trim() === "" || siblingTaken(parentId, draft, node.id));

        return (
          <motion.li
            key={node.id}
            role="none"
            layout={noMotion ? false : "position"}
            initial={noMotion ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }}
            animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
            exit={
              noMotion
                ? { opacity: 0, transition: { duration: 0.1 } }
                : {
                    opacity: 0,
                    y: -6,
                    filter: "blur(4px)",
                    transition: { duration: 0.15, ease: [0.4, 0, 1, 1] },
                  }
            }
            transition={{ type: "spring", duration: 0.3, bounce: 0 }}
          >
            <div
              role="treeitem"
              aria-level={depth}
              aria-expanded={isFolder ? isExpanded : undefined}
              aria-selected={isSelected}
              tabIndex={!isEditing && focusTarget === node.id ? 0 : -1}
              ref={(el) => {
                if (el) rowRefs.current.set(node.id, el);
                else rowRefs.current.delete(node.id);
              }}
              onClick={() => {
                if (!isEditing) activate(node);
              }}
              onKeyDown={(event) => handleKeyDown(event, node)}
              onFocus={() => setFocused(node.id)}
              className={cn(
                "group relative isolate flex h-7 cursor-default items-center gap-1.5 rounded-md px-2 text-sm select-none",
                "transition-colors duration-(--duration-fast)",
                isSelected
                  ? "text-foreground"
                  : "text-muted-foreground hover:bg-muted/40 hover:text-foreground",
              )}
            >
              {isSelected &&
                (noMotion ? (
                  <span
                    aria-hidden
                    className="pointer-events-none absolute inset-0 -z-10 rounded-md bg-accent"
                  />
                ) : (
                  <motion.span
                    aria-hidden
                    layoutId={`${uid}-highlight`}
                    transition={{ type: "spring", duration: 0.3, bounce: 0 }}
                    className="pointer-events-none absolute inset-0 -z-10 rounded-md bg-accent"
                  />
                ))}

              {isFolder ? (
                <ChevronRight
                  aria-hidden
                  className={cn(
                    "size-3.5 shrink-0 text-muted-foreground/70",
                    isExpanded && "rotate-90",
                    !isStatic &&
                      "transition-[rotate] duration-(--duration-quick) ease-(--ease-out) motion-reduce:transition-none",
                  )}
                />
              ) : (
                <span aria-hidden className="w-3.5 shrink-0" />
              )}

              <span
                aria-hidden
                className={cn(
                  "shrink-0 [&_svg]:size-4",
                  isSelected ? "text-foreground" : "text-muted-foreground",
                )}
              >
                {isFolder ? (
                  isExpanded ? (
                    <FolderOpen className="size-4" />
                  ) : (
                    <Folder className="size-4" />
                  )
                ) : (
                  <File className="size-4" />
                )}
              </span>

              {isEditing ? (
                <input
                  type="text"
                  value={draft}
                  autoFocus
                  spellCheck={false}
                  autoComplete="off"
                  aria-label={editing.isNew ? `Name new ${node.type}` : `Rename ${editing.originalName}`}
                  aria-invalid={invalid || undefined}
                  onFocus={(event) => event.currentTarget.select()}
                  onChange={(event) => setDraft(event.target.value)}
                  onKeyDown={(event) => {
                    event.stopPropagation();
                    if (event.key === "Enter") {
                      event.preventDefault();
                      commitEdit();
                    } else if (event.key === "Escape") {
                      event.preventDefault();
                      cancelEdit();
                    }
                  }}
                  onBlur={() => commitEdit(true)}
                  className={cn(
                    "h-5.5 min-w-0 flex-1 rounded-[5px] border bg-card px-1.5 text-sm text-foreground outline-none",
                    "transition-[border-color,box-shadow] duration-(--duration-fast)",
                    invalid
                      ? "border-destructive ring-[3px] ring-destructive/20"
                      : "border-ring ring-[3px] ring-ring/25",
                  )}
                  title={
                    invalid
                      ? draft.trim() === ""
                        ? "Name can’t be empty"
                        : "A sibling already has this name"
                      : undefined
                  }
                />
              ) : (
                <>
                  <span className="min-w-0 flex-1 truncate" title={node.name}>
                    {node.name}
                  </span>
                  <span
                    className={cn(
                      "flex shrink-0 items-center gap-0.5",
                      "transition-opacity duration-(--duration-fast)",
                      "pointer-fine:opacity-0 pointer-fine:group-hover:opacity-100 pointer-fine:group-focus-within:opacity-100",
                    )}
                  >
                    <RowAction
                      label={`Rename ${node.name}`}
                      tooltip="Rename (F2)"
                      onClick={(event) => {
                        event.stopPropagation();
                        startRename(node);
                      }}
                    >
                      <Pencil className="size-3" />
                    </RowAction>
                    <RowAction
                      label={`Delete ${node.name}`}
                      tooltip="Delete"
                      destructive
                      onClick={(event) => {
                        event.stopPropagation();
                        removeNode(node);
                      }}
                    >
                      <Trash2 className="size-3" />
                    </RowAction>
                  </span>
                </>
              )}
            </div>

            {isFolder && (
              <div
                className={cn(
                  "grid",
                  isExpanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
                  !isStatic &&
                    "transition-[grid-template-rows] motion-reduce:transition-none",
                  !isStatic &&
                    (isExpanded
                      ? "duration-(--duration-base) ease-(--ease-out)"
                      : "duration-(--duration-quick) ease-(--ease-exit)"),
                )}
              >
                <div inert={!isExpanded} className="min-h-0 overflow-hidden">
                  <ul
                    role="group"
                    className={cn(
                      "mt-0.5 ml-[15px] flex flex-col gap-0.5 pl-2",
                      showGuides && "border-l border-border",
                    )}
                  >
                    {renderLevel(node.children ?? [], depth + 1)}
                    {(node.children ?? []).length === 0 && !editing && (
                      <li
                        role="none"
                        className="px-2 py-1 text-xs text-muted-foreground/60 italic"
                      >
                        Empty folder
                      </li>
                    )}
                  </ul>
                </div>
              </div>
            )}
          </motion.li>
        );
      })}
    </AnimatePresence>
  );

  return (
    <TooltipProvider>
      <div
        data-slot="file-tree-editor"
        className={cn("w-full", className)}
        {...props}
      >
        <div className="mb-1.5 flex items-center justify-between gap-2 px-1">
          <span className="min-w-0 truncate text-xs font-medium text-muted-foreground">
            {label}
          </span>
          <span className="flex shrink-0 items-center gap-0.5">
            <ToolbarButton label="New file" onClick={() => startCreate("file")}>
              <FilePlus className="size-3.5" />
            </ToolbarButton>
            <ToolbarButton
              label="New folder"
              onClick={() => startCreate("folder")}
            >
              <FolderPlus className="size-3.5" />
            </ToolbarButton>
            <ToolbarButton
              label="Collapse all"
              onClick={() => setExpanded(new Set())}
            >
              <ChevronsDownUp className="size-3.5" />
            </ToolbarButton>
          </span>
        </div>

        {nodes.length === 0 ? (
          <div className="flex flex-col items-center gap-2.5 rounded-xl border border-dashed px-4 py-8 text-center">
            <p className="text-[13px] text-muted-foreground">No files yet</p>
            <button
              type="button"
              onClick={() => startCreate("file")}
              className={cn(
                "inline-flex h-8 items-center gap-1.5 rounded-lg bg-card px-3 pl-2.5 text-[13px] font-medium shadow-border",
                "transition-[scale,box-shadow,background-color] duration-150 ease-out hover:shadow-border-hover dark:bg-secondary/30",
                !isStatic && "active:not-disabled:scale-[0.97]",
              )}
            >
              <FilePlus aria-hidden className="size-3.5 text-muted-foreground" />
              New file
            </button>
          </div>
        ) : (
          <ul role="tree" aria-label={label} className="flex w-full flex-col gap-0.5">
            {renderLevel(nodes, 1)}
          </ul>
        )}

        <span role="status" aria-live="polite" className="sr-only">
          {announce}
        </span>
      </div>
    </TooltipProvider>
  );
}

function ToolbarButton({
  label,
  onClick,
  children,
}: {
  label: string;
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <button
          type="button"
          aria-label={label}
          onClick={onClick}
          className={cn(
            "pressable relative flex size-6 items-center justify-center rounded-md text-muted-foreground",
            "transition-colors duration-(--duration-fast) hover:bg-muted/60 hover:text-foreground",
            "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
          )}
        >
          {children}
        </button>
      </TooltipTrigger>
      <TooltipContent side="top">{label}</TooltipContent>
    </Tooltip>
  );
}

function RowAction({
  label,
  tooltip,
  destructive = false,
  onClick,
  children,
}: {
  label: string;
  tooltip: string;
  destructive?: boolean;
  onClick: (event: React.MouseEvent) => void;
  children: React.ReactNode;
}) {
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <button
          type="button"
          aria-label={label}
          onClick={onClick}
          className={cn(
            "relative flex size-5 items-center justify-center rounded-[5px] text-muted-foreground/80",
            "transition-colors duration-(--duration-fast)",
            destructive ? "hover:text-destructive" : "hover:text-foreground",
            "after:absolute after:top-1/2 after:left-1/2 after:size-7 after:-translate-1/2",
          )}
        >
          {children}
        </button>
      </TooltipTrigger>
      <TooltipContent side="top">{tooltip}</TooltipContent>
    </Tooltip>
  );
}