Skip to content

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.

ts
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-modeuseTree() returns a fluent builder. Declarative per-target context menus are rendered internally, the imperative api lets 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.

Documentation
Getting Started
Components
Recipes
CHANGELOG.md

Selected: docs/components

vue
<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.

Inbox
Welcome aboard
Quarterly report
PTO request
Archive
Old contracts
Spam
vue
<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.

Uploads
seed.png
Untracked

Drag a file from your OS file manager onto a folder above.

vue
<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.

Projects
alpha
beta
README.md

Right-click a row, or hover and use the button.

vue
<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>.

inbox
welcome.md
todo.md
archive
old-notes.md
CHANGELOG.md

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.

vue
<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'):

ts
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) },
  ]);
SetterFires onReceives
.folderMenu(folder => [])Right-click an expandable nodeThe folder node
.leafMenu(leaf => [])Right-click a non-expandable nodeThe leaf node
.viewportMenu(() => [])Right-click empty tree backgroundNothing (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:

ts
builder.onLeafContextMenu((leaf, ev) => {
  ev.preventDefault();
  myCustomPopover.openAt(ev.clientX, ev.clientY, leaf);
});
Event setterOverrides
.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:

ts
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 mounted

Virtualization

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.

Category 1
item-1-001.md
item-1-002.md
item-1-003.md
item-1-004.md
item-1-005.md
item-1-006.md
item-1-007.md
item-1-008.md

5 000 nodes total · only the ~15 visible rows are mounted at a time · expand more categories to see scroll stay smooth.

vue
<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:

ts
// 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 with flex: 1 + min-height: 0, grid cell with 1fr, etc.). Without a measurable viewport useVirtualList produces an empty render.
  • Row height must match. The configured itemSize is what the spacer + absolute positioning use. If your slot renders a row taller than itemSize, 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 via aria-level). <ul role="group"> nesting was dropped on purpose — it's optional under WAI-ARIA and incompatible with flat virtualization.

Accessibility

Keyboard

KeyAction
/ 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 / EndFirst / last visible row
EnterActivate (emits @activate) — typically opens the node
SpaceToggle expand on a folder; select otherwise
LetterType-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" with aria-level, aria-posinset, aria-setsize
  • Folder rows expose aria-expanded; selected rows expose aria-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:

vue
<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:

FormMeaning
trueCheck 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) => booleanCustom 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

PropTypeDefaultDescription
builderTreeBuilder<T>undefinedFluent builder from useTree(). When set, the other config props are ignored. Recommended for non-trivial cases
nodesreadonly T[]Root nodes (required in props-mode; ignored when builder is set)
getId(node: T) => stringIdentity extractor. Must be unique across the entire visible tree (required in props-mode)
getChildren(node: T) => readonly T[] | null | undefinedundefinedReturns the children of a node — return undefined or null for leaves. Without it, the tree is a flat list
getLabel(node: T) => stringundefinedUsed by type-ahead navigation. Optional but recommended for keyboard UX
isExpandable(node: T) => booleanderived from getChildrenOverride branch detection — useful when a folder should always render as expandable even if its children are lazy-loaded
draggableboolean | ((n: T) => boolean)falseAllow internal drag-to-reorder. Pass a function to enable per-node
canDrop(source: T, target: T | null, position: 'before' | 'inside' | 'after') => booleanundefinedCustom validation on top of the built-in self-into-descendant guard
acceptsFilesbooleanfalseAccept OS file drops onto folder rows / the background
autoExpandDelaynumber700Milliseconds the cursor must hover before a collapsed folder auto-expands during a drag
virtualizeboolean | { itemSize?, overscan? }falseEnable row virtualization. true uses defaults (28-px rows, 5-row overscan); pass an object to customize
v-model:expandedSet<string>empty SetIds of expanded folders. Replaced with a fresh Set on each change to trigger reactivity
v-model:selectedstring | nullnullId of the currently selected row

Events

EventPayloadFires
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

SlotPropsDescription
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.
emptyShown when nodes is empty. Defaults to nothing — provide your own empty-state copy

Exposed methods

MethodDescription
focusNode(id)Programmatically move focus to a node (call via template ref)

Constants

ConstantValueUse
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

Released under the Apache-2.0 License.