Tree
A generic, keyboard-navigable, drag-drop-aware tree primitive. Identity, children and label are extracted via prop functions — render any node shape (file systems, navigation, settings, categories, …) without forcing a common base type.
import {
CoarTree,
useTree,
type CoarTreeNodeMoveEvent,
type CoarTreeFilesDropEvent,
} from '@cocoar/vue-ui';Two APIs
<CoarTree> works in two modes:
- Props-mode — pass
nodes,getId,getChildren, etc. as props; wire<CoarContextMenu>yourself. Good for simple cases. - Builder-mode —
useTree()returns a fluent builder. Declarative per-target context menus are rendered internally, the imperativeapilets you focus nodes from outside the component. Recommended for anything beyond the basics.
The two compose — but pick one per <CoarTree> instance; mixing props and builder on the same instance is a footgun.
Desktop-first
<CoarTree> is an explicit exception to the library's tablet-first principle. Right-click context menus and hover-revealed UI on rows (⋮ buttons, inline actions) are part of the intended UX. The component still works on touch devices but isn't tuned for them — use it for power-user surfaces (file managers, settings explorers, asset trees) rather than mobile navigation.
Basic Tree
Pass a list of root nodes and four extractors: getId, getChildren, getLabel, and optionally isExpandable. Render the row body via the default slot. Two v-models — expanded (a Set<string> of node ids) and selected (a single id or null) — let the consumer control state.
Selected: docs/components
<script setup lang="ts">
/**
* The simplest possible `<CoarTree>`: a static nested structure rendered with
* the default slot. Click a row to select it, click a chevron (or use Space)
* to expand or collapse a branch.
*/
import { ref } from 'vue';
import { CoarTree, CoarIcon, vTooltip } from '@cocoar/vue-ui';
interface Node {
id: string;
label: string;
children?: Node[];
}
const tree: Node[] = [
{
id: 'docs',
label: 'Documentation',
children: [
{ id: 'docs/getting-started', label: 'Getting Started' },
{ id: 'docs/components', label: 'Components' },
{ id: 'docs/recipes', label: 'Recipes' },
],
},
{
id: 'design',
label: 'Design Tokens',
children: [
{ id: 'design/colors', label: 'Colors' },
{ id: 'design/spacing', label: 'Spacing' },
],
},
{ id: 'changelog', label: 'CHANGELOG.md' },
];
const expanded = ref(new Set<string>(['docs']));
const selected = ref<string | null>('docs/components');
</script>
<template>
<div class="tree-frame">
<CoarTree
:nodes="tree"
:get-id="(n: Node) => n.id"
:get-children="(n: Node) => n.children"
:get-label="(n: Node) => n.label"
v-model:expanded="expanded"
v-model:selected="selected"
>
<template #default="{ node }">
<span
v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
class="tree-row__main"
>
<CoarIcon
:name="node.children ? 'folder' : 'file-text'"
size="xs"
class="tree-row__icon"
/>
<span class="tree-row__label">{{ node.label }}</span>
</span>
</template>
</CoarTree>
</div>
<p class="tree-state">Selected: <code>{{ selected ?? '—' }}</code></p>
</template>
<style scoped>
.tree-frame {
border: 1px solid var(--coar-border-neutral-secondary);
border-radius: 8px;
padding: 4px 0;
max-width: 360px;
}
.tree-row__main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.tree-row__icon {
color: var(--coar-text-neutral-tertiary);
flex-shrink: 0;
}
.tree-row__label {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-state {
margin-top: 12px;
font-size: 13px;
color: var(--coar-text-neutral-secondary);
}
</style>
Drag-and-Drop Reorder
Set draggable to allow internal moves. Drop between sibling rows to reorder; drop into a folder (middle 50 % of the row) to move it inside. The tree rejects self-onto-descendant drops, draws a 2-pixel indicator line for before/after and a dashed outline for inside, and auto-expands collapsed folders after a short hover.
The consumer handles the actual mutation in @node-move — splice the source out of its current parent and re-insert it at the target's location.
<script setup lang="ts">
/**
* Internal drag-and-drop: pick a node, drag onto another. Drop **between**
* siblings (top / bottom edge of the row) to reorder; drop **into** a folder
* (middle 50 % of the row) to move it inside. Self-onto-descendant is rejected
* by the tree automatically. Hovering a collapsed folder for ~700 ms while
* dragging auto-expands it so deeper drops are reachable.
*/
import { ref } from 'vue';
import {
CoarTree,
CoarIcon,
vTooltip,
type CoarTreeNodeMoveEvent,
} from '@cocoar/vue-ui';
interface Node {
id: string;
label: string;
children?: Node[];
}
const tree = ref<Node[]>([
{
id: 'inbox',
label: 'Inbox',
children: [
{ id: 'inbox/a', label: 'Welcome aboard' },
{ id: 'inbox/b', label: 'Quarterly report' },
{ id: 'inbox/c', label: 'PTO request' },
],
},
{
id: 'archive',
label: 'Archive',
children: [{ id: 'archive/x', label: 'Old contracts' }],
},
{ id: 'spam', label: 'Spam' },
]);
const expanded = ref(new Set<string>(['inbox', 'archive']));
const selected = ref<string | null>(null);
function findLoc(id: string, nodes: Node[] = tree.value, parent: Node | null = null): { parent: Node | null; idx: number } | null {
const idx = nodes.findIndex((n) => n.id === id);
if (idx >= 0) return { parent, idx };
for (const n of nodes) {
if (n.children) {
const found = findLoc(id, n.children, n);
if (found) return found;
}
}
return null;
}
function moveNode({ source, target, position }: CoarTreeNodeMoveEvent<Node>) {
const loc = findLoc(source.id);
if (!loc) return;
const arr = loc.parent ? loc.parent.children! : tree.value;
const [node] = arr.splice(loc.idx, 1);
if (!target) {
tree.value.push(node);
return;
}
if (position === 'inside') {
if (!target.children) target.children = [];
target.children.push(node);
expanded.value = new Set(expanded.value).add(target.id);
return;
}
const targetLoc = findLoc(target.id);
if (!targetLoc) return;
const dst = targetLoc.parent ? targetLoc.parent.children! : tree.value;
const at = position === 'before' ? targetLoc.idx : targetLoc.idx + 1;
dst.splice(at, 0, node);
}
</script>
<template>
<div class="tree-frame">
<CoarTree
:nodes="tree"
:get-id="(n: Node) => n.id"
:get-children="(n: Node) => n.children"
:get-label="(n: Node) => n.label"
:is-expandable="(n: Node) => Array.isArray(n.children)"
v-model:expanded="expanded"
v-model:selected="selected"
draggable
@node-move="moveNode"
>
<template #default="{ node }">
<span
v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
class="tree-row__main"
>
<CoarIcon
:name="node.children ? 'folder' : 'file-text'"
size="xs"
class="tree-row__icon"
/>
<span class="tree-row__label">{{ node.label }}</span>
</span>
</template>
</CoarTree>
</div>
</template>
<style scoped>
.tree-frame {
border: 1px solid var(--coar-border-neutral-secondary);
border-radius: 8px;
padding: 4px 0;
max-width: 360px;
}
.tree-row__main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.tree-row__icon {
color: var(--coar-text-neutral-tertiary);
flex-shrink: 0;
}
.tree-row__label {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
OS File Drop
Set accepts-files to receive operating-system file drops onto folder rows or the empty background. The tree emits @files-drop with the raw FileList plus the target folder (or null when dropped on the background). Use this for asset uploaders, image inboxes, or any flow where the user drags files in from outside the browser.
Drag a file from your OS file manager onto a folder above.
<script setup lang="ts">
/**
* Drop **OS files** from your file manager onto a folder row (or the empty
* tree background). The tree highlights the drop target and emits a
* `@files-drop` event with the `FileList` plus the target folder (or `null`
* for the background). The consumer decides what to do — here we just append
* a placeholder leaf per dropped file.
*/
import { ref } from 'vue';
import {
CoarTree,
CoarIcon,
vTooltip,
type CoarTreeFilesDropEvent,
} from '@cocoar/vue-ui';
interface Node {
id: string;
label: string;
children?: Node[];
}
const tree = ref<Node[]>([
{
id: 'uploads',
label: 'Uploads',
children: [{ id: 'uploads/seed.png', label: 'seed.png' }],
},
{ id: 'untracked', label: 'Untracked', children: [] },
]);
const expanded = ref(new Set<string>(['uploads', 'untracked']));
const selected = ref<string | null>(null);
function findFolder(id: string, nodes: Node[] = tree.value): Node | null {
for (const n of nodes) {
if (!n.children) continue;
if (n.id === id) return n;
const inner = findFolder(id, n.children);
if (inner) return inner;
}
return null;
}
function onFilesDrop({ files, target }: CoarTreeFilesDropEvent<Node>) {
// Convert FileList to placeholder leaves — a real app would inspect each
// file, build the right node, and likely kick off an upload.
const dropped: Node[] = Array.from(files).map((f) => ({
id: `dropped-${crypto.randomUUID()}`,
label: f.name,
}));
if (target) {
const folder = findFolder(target.id);
if (folder) {
folder.children = [...(folder.children ?? []), ...dropped];
expanded.value = new Set(expanded.value).add(folder.id);
}
} else {
tree.value.push(...dropped);
}
}
</script>
<template>
<div class="tree-frame">
<CoarTree
:nodes="tree"
:get-id="(n: Node) => n.id"
:get-children="(n: Node) => n.children"
:get-label="(n: Node) => n.label"
:is-expandable="(n: Node) => Array.isArray(n.children)"
v-model:expanded="expanded"
v-model:selected="selected"
accepts-files
@files-drop="onFilesDrop"
>
<template #default="{ node }">
<span
v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
class="tree-row__main"
>
<CoarIcon
:name="node.children ? 'folder' : 'file'"
size="xs"
class="tree-row__icon"
/>
<span class="tree-row__label">{{ node.label }}</span>
</span>
</template>
</CoarTree>
</div>
<p class="hint">Drag a file from your OS file manager onto a folder above.</p>
</template>
<style scoped>
.tree-frame {
border: 1px solid var(--coar-border-neutral-secondary);
border-radius: 8px;
padding: 4px 0;
max-width: 360px;
}
.tree-row__main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.tree-row__icon {
color: var(--coar-text-neutral-tertiary);
flex-shrink: 0;
}
.tree-row__label {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hint {
margin-top: 12px;
font-size: 12px;
color: var(--coar-text-neutral-tertiary);
}
</style>
Context Menu + ⋮ Button
@context-menu fires on right-click of any row and on background right-click (node is null). Wire it to a single useContextMenu() controller — the menu's contents adapt to whether a folder, file, or background was hit. The same controller doubles as the click handler for a hover-revealed ⋮ button per row, giving keyboard / left-click users equal access.
Right-click a row, or hover and use the ⋮ button.
<script setup lang="ts">
/**
* Wire `@context-menu` to a `useContextMenu()` controller and a single
* `<CoarContextMenu>` instance — the menu's contents adapt to the right-
* clicked node (folder vs file vs empty background).
*
* The same controller also opens from a hover-revealed `⋮` button per row,
* giving keyboard / non-right-click users the same actions.
*/
import { ref } from 'vue';
import {
CoarTree,
CoarIcon,
CoarContextMenu,
CoarMenu,
CoarMenuItem,
CoarMenuDivider,
useContextMenu,
vTooltip,
} from '@cocoar/vue-ui';
interface Node {
id: string;
label: string;
children?: Node[];
}
const tree = ref<Node[]>([
{
id: 'projects',
label: 'Projects',
children: [
{ id: 'projects/alpha', label: 'alpha' },
{ id: 'projects/beta', label: 'beta' },
],
},
{ id: 'README.md', label: 'README.md' },
]);
const expanded = ref(new Set<string>(['projects']));
const selected = ref<string | null>(null);
const menu = useContextMenu();
const target = ref<Node | null>(null);
function open(node: Node | null, ev: MouseEvent) {
target.value = node;
menu.open(ev);
}
function rename() {
if (!target.value) return;
const next = window.prompt('Rename to:', target.value.label);
if (next) target.value.label = next.trim() || target.value.label;
}
function remove() {
if (!target.value) return;
const id = target.value.id;
const drop = (list: Node[]): boolean => {
const i = list.findIndex((n) => n.id === id);
if (i >= 0) { list.splice(i, 1); return true; }
return list.some((n) => n.children && drop(n.children));
};
drop(tree.value);
}
</script>
<template>
<div class="tree-frame">
<CoarTree
:nodes="tree"
:get-id="(n: Node) => n.id"
:get-children="(n: Node) => n.children"
:get-label="(n: Node) => n.label"
:is-expandable="(n: Node) => Array.isArray(n.children)"
v-model:expanded="expanded"
v-model:selected="selected"
@context-menu="open"
>
<template #default="{ node }">
<span
v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
class="tree-row__main"
>
<CoarIcon
:name="node.children ? 'folder' : 'file-text'"
size="xs"
class="tree-row__icon"
/>
<span class="tree-row__label">{{ node.label }}</span>
</span>
<button
type="button"
class="tree-row__more"
aria-label="More actions"
@click.stop="open(node, $event)"
>
<CoarIcon name="ellipsis-vertical" size="xs" />
</button>
</template>
</CoarTree>
</div>
<CoarContextMenu :menu="menu">
<CoarMenu>
<template v-if="target">
<CoarMenuItem label="Rename…" icon="pencil" @clicked="rename" />
<CoarMenuDivider />
<CoarMenuItem label="Delete" icon="trash-2" @clicked="remove" />
</template>
<template v-else>
<CoarMenuItem label="(Right-click a row to see context-aware actions)" disabled />
</template>
</CoarMenu>
</CoarContextMenu>
<p class="hint">Right-click a row, or hover and use the <code>⋮</code> button.</p>
</template>
<style scoped>
.tree-frame {
border: 1px solid var(--coar-border-neutral-secondary);
border-radius: 8px;
padding: 4px 0;
max-width: 360px;
}
.tree-row__main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.tree-row__icon {
color: var(--coar-text-neutral-tertiary);
flex-shrink: 0;
}
.tree-row__label {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-row__more {
width: 20px;
height: 20px;
border: none;
background: transparent;
border-radius: 3px;
color: var(--coar-text-neutral-tertiary);
cursor: pointer;
opacity: 0;
transition: opacity 120ms ease;
}
:deep(.coar-tree-node__row:hover) .tree-row__more,
:deep(.coar-tree-node__row:focus-within) .tree-row__more {
opacity: 1;
}
.tree-row__more:hover {
background: var(--coar-background-neutral-primary);
color: var(--coar-text-neutral-primary);
}
.hint {
margin-top: 12px;
font-size: 12px;
color: var(--coar-text-neutral-tertiary);
}
</style>
Builder API
useTree<T>() returns { builder, api }. Configure data, behavior, handlers and three context menus in a single fluent chain — the tree renders the <CoarContextMenu> itself, so the template is just <CoarTree :builder>.
Right-click a folder, a file, or the empty area below — each has its own menu. useTree() exposes api.selectedId (currently —) and api.focusNode(id) for imperative control.
<script setup lang="ts">
/**
* Full builder-mode demo. Configures data + handlers + per-target context
* menus in a single fluent chain. The tree renders the `<CoarContextMenu>`
* internally — no extra markup in the template.
*
* Three menu types are wired:
* - `folderMenu` — right-click a folder row
* - `leafMenu` — right-click a file row
* - `viewportMenu` — right-click the empty background (e.g. below the rows)
*/
import { ref } from 'vue';
import {
CoarTree,
CoarIcon,
useTree,
vTooltip,
type CoarTreeNodeMoveEvent,
} from '@cocoar/vue-ui';
interface Node {
id: string;
label: string;
children?: Node[];
}
const uid = () => crypto.randomUUID();
const tree = ref<Node[]>([
{
id: uid(),
label: 'inbox',
children: [
{ id: uid(), label: 'welcome.md' },
{ id: uid(), label: 'todo.md' },
],
},
{
id: uid(),
label: 'archive',
children: [{ id: uid(), label: 'old-notes.md' }],
},
{ id: uid(), label: 'CHANGELOG.md' },
]);
const expanded = ref(new Set<string>(tree.value.filter((n) => n.children).map((n) => n.id)));
const selected = ref<string | null>(null);
const lastActivated = ref<string | null>(null);
// Find a node's parent + index so we can mutate the tree in place.
function findLoc(id: string, nodes: Node[] = tree.value, parent: Node | null = null): { parent: Node | null; idx: number } | null {
const idx = nodes.findIndex((n) => n.id === id);
if (idx >= 0) return { parent, idx };
for (const n of nodes) {
if (n.children) {
const found = findLoc(id, n.children, n);
if (found) return found;
}
}
return null;
}
function addFolder(parentId: string | null) {
const label = window.prompt('Folder name?')?.trim();
if (!label) return;
const node: Node = { id: uid(), label, children: [] };
if (parentId) {
const parent = findLoc(parentId)?.parent === null ? tree.value.find((n) => n.id === parentId) : null;
const found = tree.value.find((n) => n.id === parentId);
if (found?.children) found.children.push(node);
expanded.value = new Set(expanded.value).add(parentId);
} else {
tree.value.push(node);
}
}
function remove(id: string) {
const loc = findLoc(id);
if (!loc) return;
const arr = loc.parent ? loc.parent.children! : tree.value;
arr.splice(loc.idx, 1);
}
function move({ source, target, position }: CoarTreeNodeMoveEvent<Node>) {
const loc = findLoc(source.id);
if (!loc) return;
const arr = loc.parent ? loc.parent.children! : tree.value;
const [node] = arr.splice(loc.idx, 1);
if (!target) { tree.value.push(node); return; }
if (position === 'inside') {
if (!target.children) target.children = [];
target.children.push(node);
expanded.value = new Set(expanded.value).add(target.id);
return;
}
const targetLoc = findLoc(target.id);
if (!targetLoc) return;
const dst = targetLoc.parent ? targetLoc.parent.children! : tree.value;
dst.splice(position === 'before' ? targetLoc.idx : targetLoc.idx + 1, 0, node);
}
const { builder, api } = useTree<Node>();
builder
.nodes(tree)
.getId((n) => n.id)
.getChildren((n) => n.children)
.getLabel((n) => n.label)
.isExpandable((n) => Array.isArray(n.children))
.expanded(expanded)
.selected(selected)
.draggable(true)
.onActivate((n) => { lastActivated.value = n.label; })
.onNodeMove(move)
.folderMenu((folder) => [
{ label: 'New subfolder…', icon: 'plus', onClick: () => addFolder(folder.id) },
'divider',
{ label: 'Delete folder', icon: 'trash-2', danger: true, onClick: () => remove(folder.id) },
])
.leafMenu((leaf) => [
{ label: 'Open', icon: 'file', onClick: () => { lastActivated.value = leaf.label; } },
'divider',
{ label: 'Delete', icon: 'trash-2', danger: true, onClick: () => remove(leaf.id) },
])
.viewportMenu(() => [
{ label: 'New folder at root…', icon: 'plus', onClick: () => addFolder(null) },
]);
</script>
<template>
<div class="tree-frame">
<CoarTree :builder="builder">
<template #default="{ node }">
<span
v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
class="tree-row__main"
>
<CoarIcon
:name="node.children ? 'folder' : 'file-text'"
size="xs"
class="tree-row__icon"
/>
<span class="tree-row__label">{{ node.label }}</span>
</span>
</template>
</CoarTree>
</div>
<p class="hint">
Right-click a folder, a file, or the empty area below — each has its own menu.
<code>useTree()</code> exposes <code>api.selectedId</code> (currently <code>{{ api.selectedId.value ?? '—' }}</code>)
and <code>api.focusNode(id)</code> for imperative control.
</p>
<p v-if="lastActivated" class="hint">Last activated: <code>{{ lastActivated }}</code></p>
</template>
<style scoped>
.tree-frame {
border: 1px solid var(--coar-border-neutral-secondary);
border-radius: 8px;
padding: 4px 0;
max-width: 360px;
min-height: 220px;
}
.tree-row__main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.tree-row__icon {
color: var(--coar-text-neutral-tertiary);
flex-shrink: 0;
}
.tree-row__label {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hint {
margin-top: 12px;
font-size: 12px;
color: var(--coar-text-neutral-tertiary);
}
</style>
Context menus per target
Three setters, one per target type. Each callback returns an array of menu entries ({ label, icon?, danger?, disabled?, onClick } or the literal string 'divider'):
builder
.folderMenu(folder => [
{ label: 'Upload here', icon: 'upload', onClick: () => upload(folder.id) },
{ label: 'New subfolder', icon: 'plus', onClick: () => newFolder(folder.id) },
'divider',
{ label: 'Delete', icon: 'trash-2', danger: true, onClick: () => del(folder) },
])
.leafMenu(leaf => [
{ label: 'Open', icon: 'file', onClick: () => openFile(leaf) },
{ label: 'Delete', icon: 'trash-2', danger: true, onClick: () => del(leaf) },
])
.viewportMenu(() => [
{ label: 'New folder', icon: 'plus', onClick: () => newFolder(null) },
]);| Setter | Fires on | Receives |
|---|---|---|
.folderMenu(folder => []) | Right-click an expandable node | The folder node |
.leafMenu(leaf => []) | Right-click a non-expandable node | The leaf node |
.viewportMenu(() => []) | Right-click empty tree background | Nothing (null target) |
If a setter is omitted for a target, no menu opens on that target. Items without an icon align flush left; the tree allocates the icon column when any item in the menu uses an icon.
Escape hatch — raw events
When you want a fully custom popover (form inputs, async sub-menus, third-party menu component) instead of the standard menu, use the event variants. They override the declarative setters for that target:
builder.onLeafContextMenu((leaf, ev) => {
ev.preventDefault();
myCustomPopover.openAt(ev.clientX, ev.clientY, leaf);
});| Event setter | Overrides |
|---|---|
.onFolderContextMenu((folder, ev) => …) | .folderMenu |
.onLeafContextMenu((leaf, ev) => …) | .leafMenu |
.onViewportContextMenu((ev) => …) | .viewportMenu |
The api
useTree()'s second return value is a narrow imperative interface — call from anywhere without needing a template ref:
const { builder, api } = useTree<MyNode>();
// readonly refs (suitable for watch / computed)
api.selectedId // Ref<string | null>
api.expandedIds // Ref<Set<string>>
// imperative
api.focusNode('some-id') // selects + focuses the node; warns until mountedVirtualization
For trees with hundreds or thousands of visible rows, enable virtualization to mount only the rows actually inside the viewport. The component is built on useVirtualList — fixed-known-size virtualizer, no DOM auto-measure — so you declare the row height once.
5 000 nodes total · only the ~15 visible rows are mounted at a time · expand more categories to see scroll stay smooth.
<script setup lang="ts">
/**
* Virtualized tree with 5 000 nodes across two levels. Without virtualization
* this would mount 5 000 row components — with `.virtualize({ itemSize: 28 })`
* only the rows inside the viewport (~15 at this size) + a 5-row overscan
* are mounted, regardless of how far the user scrolls.
*
* Try expanding one of the "Category" folders — each holds 250 entries.
* Scrolling stays smooth even at thousands of nodes.
*/
import { ref } from 'vue';
import { CoarTree, CoarIcon, useTree, vTooltip } from '@cocoar/vue-ui';
interface Node {
id: string;
label: string;
children?: Node[];
}
// 20 categories × 250 items = 5 000 leaves, plus the 20 folders themselves.
const tree: Node[] = Array.from({ length: 20 }, (_, c) => ({
id: `cat-${c}`,
label: `Category ${c + 1}`,
children: Array.from({ length: 250 }, (_, i) => ({
id: `cat-${c}-item-${i}`,
label: `item-${c + 1}-${String(i + 1).padStart(3, '0')}.md`,
})),
}));
const expanded = ref(new Set<string>(['cat-0'])); // first category open by default
const selected = ref<string | null>(null);
const { builder } = useTree<Node>();
builder
.nodes(tree)
.getId((n) => n.id)
.getChildren((n) => n.children)
.getLabel((n) => n.label)
.expanded(expanded)
.selected(selected)
// Default itemSize is 28px which matches the standard row layout. Bump it
// when the slot adds extra padding or multi-line content. Overscan of 8
// gives a slightly smoother scroll on slow GPUs.
.virtualize({ itemSize: 28, overscan: 8 });
</script>
<template>
<div class="tree-frame">
<CoarTree :builder="builder">
<template #default="{ node }">
<span
v-tooltip="{ content: node.label, onlyOnOverflow: '.tree-row__label' }"
class="tree-row__main"
>
<CoarIcon
:name="node.children ? 'folder' : 'file-text'"
size="xs"
class="tree-row__icon"
/>
<span class="tree-row__label">{{ node.label }}</span>
</span>
</template>
</CoarTree>
</div>
<p class="hint">
5 000 nodes total · only the ~15 visible rows are mounted at a time · expand more categories to see scroll stay smooth.
</p>
</template>
<style scoped>
.tree-frame {
border: 1px solid var(--coar-border-neutral-secondary);
border-radius: 8px;
max-width: 360px;
/* Virtualization needs an explicit height for the scroll viewport. Without
one the tree would collapse to 0 height (flex / grid auto sizing) and
useVirtualList wouldn't have anything to measure. */
height: 320px;
display: flex;
}
.tree-row__main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.tree-row__icon {
color: var(--coar-text-neutral-tertiary);
flex-shrink: 0;
}
.tree-row__label {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hint {
margin-top: 12px;
font-size: 12px;
color: var(--coar-text-neutral-tertiary);
}
</style>
Configuration:
// builder mode
builder.virtualize(true); // defaults: itemSize 28, overscan 5
builder.virtualize({ itemSize: 32 }); // uniform 32-px rows
builder.virtualize({ itemSize: (i) => myHeights[i] }); // variable per visible-row
builder.virtualize({ itemSize: 28, overscan: 10 }); // larger scroll buffer
// props mode
<CoarTree :virtualize="true" ... />
<CoarTree :virtualize="{ itemSize: 32 }" ... />Requirements + caveats:
- Explicit height. The tree owns its scroll container when virtualized. Put it in a sized parent (fixed
height, flex withflex: 1+min-height: 0, grid cell with1fr, etc.). Without a measurable viewportuseVirtualListproduces an empty render. - Row height must match. The configured
itemSizeis what the spacer + absolute positioning use. If your slot renders a row taller thanitemSize, content overflows; shorter and there's empty space. Measure your row in DevTools and configure accordingly. - No auto-measurement. This is a fixed-known-size virtualizer (unlike TanStack Virtual's
measureElement). For trees with truly dynamic row heights (e.g. wrapping multi-line labels), keep virtualization off — performance is fine up to ~500 visible rows even without it. - Both modes use flat rendering. Whether virtualized or not, the tree renders rows as a flat DFS list (depth via
padding-left, ARIA hierarchy viaaria-level).<ul role="group">nesting was dropped on purpose — it's optional under WAI-ARIA and incompatible with flat virtualization.
Accessibility
Keyboard
| Key | Action |
|---|---|
↑ / ↓ | Move focus to previous / next visible row |
→ | Expand a collapsed folder; otherwise descend to first child |
← | Collapse an expanded folder; otherwise ascend to parent |
Home / End | First / last visible row |
Enter | Activate (emits @activate) — typically opens the node |
Space | Toggle expand on a folder; select otherwise |
| Letter | Type-ahead: jump to next visible row whose label starts with the typed prefix (resets after 500 ms) |
ARIA
- Root has
role="tree" - Each row has
role="treeitem"witharia-level,aria-posinset,aria-setsize - Folder rows expose
aria-expanded; selected rows exposearia-selected="true" - Nested
<ul role="group">wraps each branch's children
Recipes
Truncation in narrow sidebars
The tree owns indentation and the chevron but doesn't render the label — the consumer does, via the default slot. The standard pattern is ellipsis + v-tooltip with onlyOnOverflow: true on the label span — the styled Coar tooltip appears only when the text is actually clipped, and stays out of the way when the full label is visible:
<script setup lang="ts">
import { vTooltip } from '@cocoar/vue-ui';
</script>
<template #default="{ node }">
<!--
The tooltip lives on a wrapper around the icon + label — NOT the label
alone. When the row gets so narrow that the label collapses to 0 px
(deep nesting in a slim sidebar), the icon still has a hit area and the
tooltip remains reachable. The string form of `onlyOnOverflow` tells the
directive to gate on the *child label's* overflow, not the wrapper's.
-->
<span
v-tooltip="{ content: node.label, onlyOnOverflow: '.label' }"
class="row-main"
>
<CoarIcon :name="node.children ? 'folder' : 'file'" size="xs" />
<span class="label">{{ node.label }}</span>
</span>
</template>
<style>
.row-main {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
}
.label {
flex: 1;
min-width: 0; /* allow shrink inside the flex row */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>onlyOnOverflow accepts three forms — all evaluated lazily on hover/focus, no ResizeObserver overhead:
| Form | Meaning |
|---|---|
true | Check scrollWidth > clientWidth on the trigger element itself |
string (CSS selector) | Check overflow on the matched descendant — use when the tooltip is on a wrapper but only an inner element is what gets truncated |
function: (el) => boolean | Custom predicate; return true to show the tooltip |
Scale
A tree with hundreds of mounted rows means hundreds of v-tooltip directive listeners. Combine this pattern with virtualization for very large trees — only the ~visible rows hold listeners. For low-overhead plain-text fallback (e.g. mobile / embedded contexts where the overlay system isn't installed), use the native :title attribute instead — same UX, zero JS.
File-explorer shell
Combine <CoarTree> with CoarTabGroup, CoarScriptEditor, CoarMarkdownEditor, and CoarDocumentViewer to get a VS-Code-style document explorer. The tree owns the asset hierarchy and DnD; tabs hold the open editors; per-row ⋮ and right-click expose CRUD actions through a single <CoarContextMenu>.
Multi-tree drag
Two <CoarTree> instances on the same page can exchange nodes via the shared application/x-coar-tree-node MIME type. Each tree's @node-move only fires when the drop lands on one of its own rows — you decide on the consumer side how to coordinate the source/target trees (e.g. a parent component that owns both data stores).
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
builder | TreeBuilder<T> | undefined | Fluent builder from useTree(). When set, the other config props are ignored. Recommended for non-trivial cases |
nodes | readonly T[] | — | Root nodes (required in props-mode; ignored when builder is set) |
getId | (node: T) => string | — | Identity extractor. Must be unique across the entire visible tree (required in props-mode) |
getChildren | (node: T) => readonly T[] | null | undefined | undefined | Returns the children of a node — return undefined or null for leaves. Without it, the tree is a flat list |
getLabel | (node: T) => string | undefined | Used by type-ahead navigation. Optional but recommended for keyboard UX |
isExpandable | (node: T) => boolean | derived from getChildren | Override branch detection — useful when a folder should always render as expandable even if its children are lazy-loaded |
draggable | boolean | ((n: T) => boolean) | false | Allow internal drag-to-reorder. Pass a function to enable per-node |
canDrop | (source: T, target: T | null, position: 'before' | 'inside' | 'after') => boolean | undefined | Custom validation on top of the built-in self-into-descendant guard |
acceptsFiles | boolean | false | Accept OS file drops onto folder rows / the background |
autoExpandDelay | number | 700 | Milliseconds the cursor must hover before a collapsed folder auto-expands during a drag |
virtualize | boolean | { itemSize?, overscan? } | false | Enable row virtualization. true uses defaults (28-px rows, 5-row overscan); pass an object to customize |
v-model:expanded | Set<string> | empty Set | Ids of expanded folders. Replaced with a fresh Set on each change to trigger reactivity |
v-model:selected | string | null | null | Id of the currently selected row |
Events
| Event | Payload | Fires |
|---|---|---|
activate | (node: T) | Double-click on a row or Enter on the focused row |
context-menu | (node: T | null, ev: MouseEvent) | Right-click on a row (node set) or background (node is null). The default action is suppressed automatically by useContextMenu().open(ev) |
files-drop | ({ files: FileList, target: T | null }) | OS files dropped on a folder (target set) or empty background (target is null). Only fires when accepts-files is true |
node-move | ({ source: T, target: T | null, position }) | Internal drag-drop. position is 'before', 'inside', or 'after'. target: null + 'inside' means "move to root". Only fires when draggable is truthy |
update:expanded | (Set<string>) | Folder expanded / collapsed |
update:selected | (string | null) | Selection changed |
Slots
| Slot | Props | Description |
|---|---|---|
default | { node, depth, isExpanded, isSelected, isFocused, isExpandable } | Row body. The tree renders indentation, chevron, focus ring and drop indicators; you render the icon, label, inline action buttons, dirty markers, etc. |
empty | — | Shown when nodes is empty. Defaults to nothing — provide your own empty-state copy |
Exposed methods
| Method | Description |
|---|---|
focusNode(id) | Programmatically move focus to a node (call via template ref) |
Constants
| Constant | Value | Use |
|---|---|---|
COAR_TREE_DRAG_MIME | 'application/x-coar-tree-node' | Mime type set on DataTransfer for internal drags. Read it on drop if you're orchestrating drags between two trees that share an outer container |