I seem to have a penchant for making tree view components. Here's a rough sketch of how to make a quick one in React using native HTML summary/details elements.
You need a <TreeViewNode/>
which renders its children recursively, and a parent <TreeView/>
component for some niceties. Toggling functionality is thanks to the splendid native HTML <details/>
and <summary/>
elements. You can learn more about them on the MDN docs website.
import cx from "classnames";
export interface TreeViewNodeProps
extends React.DetailsHTMLAttributes<HTMLDetailsElement> {
id: string;
title: string;
children: TreeViewNodeProps[];
}
export const TreeViewNode = ({
title,
children,
className,
...delegated
}: TreeViewNodeProps) => {
return (
<details {...delegated} className={cx(className)}>
{/* toggle */}
<summary>{title}</summary>
{/* content */}
{children.map((t) => (
<TreeViewNode {...t} key={t.id} className="pl-4" />
))}
</details>
);
};
interface TreeViewProps {
roots: TreeViewNodeProps[];
}
export const TreeView = ({ roots }: TreeViewProps) => {
return (
<div>
{roots.map((r) => (
<TreeViewNode {...r} key={r.id} />
))}
</div>
);
};
Here's how you might go a little further:
import cx from "classnames";
import { useState } from "react";
export interface TreeViewNodeProps
extends React.DetailsHTMLAttributes<HTMLDetailsElement> {
id: string;
title: string;
children: TreeViewNodeProps[];
// utilities to open/close nodes
isNodeOpen?: (nodeId: string) => boolean;
doNodeOpen?: (nodeId: string) => void;
doNodeClose?: (nodeId: string) => void;
// when node selected
onNodeSelect?: (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
id: string
) => void;
}
export const TreeViewNode = ({
id,
title,
children,
className,
isNodeOpen,
doNodeOpen,
doNodeClose,
onNodeSelect,
...delegated
}: TreeViewNodeProps) => {
return (
<details
{...delegated}
className={cx(className)}
open={isNodeOpen && isNodeOpen(id)}
>
{/* toggle */}
<summary className="flex items-center gap-2">
{/* hide toggle when no children */}
{children.length > 0 ? (
<div
onClick={(e) => {
// https://github.com/facebook/react/issues/15486#issuecomment-488028431
e.preventDefault();
if (isNodeOpen && isNodeOpen(id)) {
doNodeClose && doNodeClose(id);
} else {
doNodeOpen && doNodeOpen(id);
}
}}
>
{isNodeOpen && isNodeOpen(id) ? "⬇️ " : "➡️"}
</div>
) : (
<div className="invisible">⬇️</div>
)}
{/* title */}
<div
onClick={(e) => {
e.preventDefault();
onNodeSelect && onNodeSelect(e, id);
}}
>
{title}
</div>
</summary>
{/* content */}
{children.map((t) => (
<TreeViewNode
className="pl-6"
// props unique to this child
{...t}
key={t.id}
// props/utilities that are passed thru from upper levels
onNodeSelect={onNodeSelect}
isNodeOpen={isNodeOpen}
doNodeOpen={doNodeOpen}
doNodeClose={doNodeClose}
/>
))}
</details>
);
};
interface TreeViewProps {
roots: TreeViewNodeProps[];
onNodeSelect?: (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
id: string
) => void;
}
export const TreeView = ({ roots, onNodeSelect }: TreeViewProps) => {
// utilities to open/close nodes
const [currentlyOpenIds, setCurrentlyOpenIds] = useState(new Set<string>());
const isNodeOpen = (nodeId: string) => currentlyOpenIds.has(nodeId);
const doNodeOpen = (nodeId: string) =>
setCurrentlyOpenIds((prev) => {
const next = new Set(prev);
next.add(nodeId);
return next;
});
const doNodeClose = (nodeId: string) =>
setCurrentlyOpenIds((prev) => {
const next = new Set(prev);
next.delete(nodeId);
return next;
});
return (
<div>
{roots.map((r) => (
<TreeViewNode
{...r}
key={r.id}
// when a node is selected
onNodeSelect={onNodeSelect}
// control which nodes are open
isNodeOpen={isNodeOpen}
doNodeOpen={doNodeOpen}
doNodeClose={doNodeClose}
/>
))}
</div>
);
};
We could raise the state up even higher and make the opened nodes fully controlled from the consumer of our tree view (kind of like how the onNodeSelect
is passed in from the consumer).
Also notice how I use a Set
to figure out which nodes are open. You could use an array instead if that's simpler.
Btw if you need test data here is the data from the screenshot:
const items: TreeViewNodeProps[] = [
{
id: "tree-title-customization-item-1",
title: "one",
children: [
{
id: "tree-title-customization-item-2",
title: "one one",
children: [
{
id: "tree-title-customization-item-3",
title: "one one one",
children: [],
},
],
},
],
},
{
id: "tree-title-customization-item-4",
title: "two",
children: [],
},
];
Oh also if you already have a nested tree structure but converting the data type to the above is a pain, you can use a helper like below, which builds up a transformed output tree while traversing your input tree:
export const transformTagTreeNodeToTreeViewNode = (
input: TagTreeNode[],
output: TreeViewNodeProps[] = []
): TreeViewNodeProps[] => {
input.forEach((tagTreeNode) => {
output.push({
id: tagTreeNode.id,
title: tagTreeNode.name,
children: transformTagTreeNodeToTreeViewNode(tagTreeNode.children),
});
});
return output;
};
Ok, hopefully that's all I have to say about tree view components for a very long time! All this recursion is making my brain go a little crazy.