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.
npx shadcn@latest add @paragon/file-tree-editorAlso installs: tooltip
"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>
);
}