/ Examples
treeselectionscrollbar

Tree View

Real filesystem tree with tree + async loadChildren. Folders load on first expand via /api/files. Right expand · Left collapse · type to jump.

Files

0% 0.00 / 0.00 px/ms 0 / 0 items
Source
// Tree View — Collapsible file tree with async loading
// Demonstrates tree plugin with real filesystem data from /api/files

import { createVList, tree, selection } from "vlist";
import { createStats } from "../stats.js";
import { createInfoUpdater } from "../info.js";
import { getIcon, getChevron } from "./icons.js";
import { updateTreeState } from "./controls.js";

// =============================================================================
// Constants
// =============================================================================

const ITEM_HEIGHT = 30;
const API_BASE = "/api/files";

// =============================================================================
// Data — Filesystem
// =============================================================================

function formatSize(bytes) {
  if (bytes < 1024) return bytes + " B";
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
  return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}

export async function fetchDir(path) {
  const res = await fetch(`${API_BASE}?path=${encodeURIComponent(path)}`);
  if (!res.ok) throw new Error(`Failed to load ${path}`);
  const data = await res.json();
  return data.items.map((item) => ({
    id: path ? `${path}/${item.name}` : item.name,
    name: item.name,
    isDir: item.type === "directory",
    size: item.size,
    children: item.type === "directory" ? undefined : [],
  }));
}

// =============================================================================
// State
// =============================================================================

let _list = null;
let _rootItems = [];

export function list() { return _list; }
export function rootItems() { return _rootItems; }

// =============================================================================
// Template
// =============================================================================

const itemTemplate = (item, _index, state) => {
  const t = state.tree;
  const isFolder = item.isDir;
  const icon = getIcon(item.name, isFolder, t.expanded);
  const chevron = t.loading
    ? '<span class="tree-node__chevron tree-node__chevron--loading">◎</span>'
    : getChevron(isFolder || t.hasChildren, t.expanded);

  const meta = isFolder
    ? item.children?.length != null
      ? `${item.children.length} items`
      : ""
    : formatSize(item.size);

  return `
    <div class="tree-node">
      ${chevron}
      <span class="tree-node__icon">${icon}</span>
      <span class="tree-node__label">${item.name}</span>
      <span class="tree-node__meta">${meta}</span>
    </div>
  `;
};

// =============================================================================
// Stats
// =============================================================================

export const stats = createStats({
  getScrollPosition: () => _list?.getScrollPosition() ?? 0,
  getTotal: () => _list?.total ?? 0,
  getItemSize: () => ITEM_HEIGHT,
  getContainerSize: () =>
    document.querySelector("#list-container")?.clientHeight ?? 0,
});

const updateInfo = createInfoUpdater(stats);

// =============================================================================
// Create list
// =============================================================================

export async function createList() {
  if (_list) {
    _list.destroy();
    _list = null;
  }

  const container = document.getElementById("list-container");
  container.innerHTML = "";

  _rootItems = await fetchDir("vlist");

  const srcNode = _rootItems.find((n) => n.name === "src");
  if (srcNode) srcNode.children = await fetchDir(srcNode.id);

  _list = createVList(
    {
      container: "#list-container",
      ariaLabel: "File tree",
      item: {
        height: ITEM_HEIGHT,
        template: itemTemplate,
      },
      items: _rootItems,
    },
    [
      tree({
        children: (item) => item.children ?? [],
        label: "name",
        indent: 20,
        expanded: ["vlist/src"],
        expandOnClick: true,
        connectorLines: true,
        loadChildren: async (item) => {
          return fetchDir(item.id);
        },
      }),
      selection({ mode: "single", followFocus: true, focusOnClick: true }),
    ],
  );

  _list.on("scroll", updateInfo);
  _list.on("range:change", updateInfo);
  _list.on("velocity:change", ({ velocity }) => {
    stats.onVelocity(velocity);
    updateInfo();
  });

  _list.on("tree:expand", updateTreeState);
  _list.on("tree:collapse", updateTreeState);
  _list.on("tree:load", updateTreeState);

  updateInfo();
  updateTreeState();
}

// =============================================================================
// Initialise
// =============================================================================

createList();
/* Tree View — example styles */

/* ============================================================================
   Container
   ============================================================================ */

#list-container {
    height: 580px;
    width: 360px;
}

#list-container .vlist {
    border-radius: 0;
}

/* ============================================================================
   Tree Node
   ============================================================================ */

.tree-node {
    display: flex;
    align-items: center;
    gap: 4px;
    height: 100%;
    width: 100%;
    padding-right: 16px;
    cursor: default;
    user-select: none;
}

/* ── Chevron ── */

.tree-node__chevron {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 16px;
    height: 16px;
    margin-left: 2px;
    flex-shrink: 0;
    border-radius: 3px;
}

.tree-node__chevron:hover {
    color: var(--text, #111827);
    background: var(--bg-hover, rgba(0, 0, 0, 0.04));
}

.tree-node__chevron--leaf {
    visibility: hidden;
}

/* ── Icon ── */

.tree-node__icon {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 22px;
    height: 22px;
    flex-shrink: 0;
    overflow: visible;
}

.tree-node__icon svg {
    overflow: visible;
}

/* ── Label ── */

.tree-node__label {
    flex: 1;
    min-width: 0;
    font-size: 14px;
    font-weight: 600;
    color: var(--text, #111827);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    line-height: 1;
}

/* ── Meta (file size / item count) ── */

.tree-node__meta {
    font-size: 11px;
    color: var(--text-dim, #9ca3af);
    font-variant-numeric: tabular-nums;
    flex-shrink: 0;
}

/* ============================================================================
   Selection + Focus
   ============================================================================ */

#list-container .vlist-item--selected {
    background: var(--accent-bg, rgba(102, 126, 234, 0.12));
}

#list-container .vlist-item--focused {
    outline: 1px solid var(--accent, #667eea);
    outline-offset: -1px;
}

#list-container .vlist-item--focused .tree-node {
    outline: none;
}

/* ============================================================================
   Display toggles
   ============================================================================ */

.hide-chevrons .tree-node__chevron {
    display: none;
}

.hide-chevrons .tree-node {
    padding-left: 6px;
}

.hide-chevrons .vlist--tree-lines {
    --vlist-tree-guide-start: 14px;
}

.hide-branches .vlist-tree-node::after {
    display: none !important;
}

.tree-toggle-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-size: 13px;
    color: var(--text-muted, #6b7280);
}

/* ============================================================================
   Sidebar
   ============================================================================ */

.tree-state {
    font-size: 12px;
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 8px;
}

.tree-state__row {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.tree-state__label {
    color: var(--text-muted, #6b7280);
}

.tree-state__row strong {
    font-variant-numeric: tabular-nums;
    color: var(--text, #111827);
}
<div class="container">
    <header>
        <h1>Tree View</h1>
        <p class="description">
            Real filesystem tree with <code>tree</code> + async
            <code>loadChildren</code>. Folders load on first expand via
            <code>/api/files</code>. <kbd>Right</kbd> expand ·
            <kbd>Left</kbd> collapse · type to jump.
        </p>
    </header>

    <div class="split-layout">
        <div class="split-main">
            <h2 class="sr-only">Files</h2>
            <div id="list-container"></div>
        </div>

        <aside class="split-panel">
            <!-- Tree state -->
            <section class="ui-section">
                <h3 class="ui-title">Tree</h3>
                <div class="ui-card ui-card--compact tree-state">
                    <div class="tree-state__row">
                        <span class="tree-state__label">Items</span>
                        <strong id="info-visible">0</strong>
                    </div>
                    <div class="tree-state__row">
                        <span class="tree-state__label">Rendered</span>
                        <strong id="info-rendered">0</strong>
                    </div>
                    <div class="tree-state__row">
                        <span class="tree-state__label">Expanded</span>
                        <strong id="info-expanded">0</strong>
                    </div>
                </div>
            </section>

            <!-- Actions -->
            <section class="ui-section">
                <h3 class="ui-title">Actions</h3>
                <div class="ui-row">
                    <div class="ui-btn-group">
                        <button id="btn-expand-all" class="ui-btn">
                            Expand all
                        </button>
                        <button id="btn-collapse-all" class="ui-btn">
                            Collapse all
                        </button>
                        <button id="btn-reset" class="ui-btn">Reset</button>
                    </div>
                </div>
            </section>

            <!-- Display -->
            <section class="ui-section">
                <h3 class="ui-title">Display</h3>
                <div class="ui-row tree-toggle-row">
                    <span>Chevrons</span>
                    <label class="ui-switch">
                        <input type="checkbox" id="toggle-chevrons" />
                        <span class="ui-switch__track"></span>
                    </label>
                </div>
                <div class="ui-row tree-toggle-row">
                    <span>Branches</span>
                    <label class="ui-switch">
                        <input type="checkbox" id="toggle-branches" />
                        <span class="ui-switch__track"></span>
                    </label>
                </div>
            </section>

            <!-- Keyboard -->
            <section class="ui-section">
                <h3 class="ui-title">Keyboard</h3>
                <div class="ui-row no-margin">
                    <span class="ui-label">&rarr;</span>
                    <span class="ui-value">Expand</span>
                </div>
                <div class="ui-row no-margin">
                    <span class="ui-label">&larr;</span>
                    <span class="ui-value">Collapse / parent</span>
                </div>
                <div class="ui-row no-margin">
                    <span class="ui-label">&uarr; / &darr;</span>
                    <span class="ui-value">Navigate</span>
                </div>
                <div class="ui-row no-margin">
                    <span class="ui-label">*</span>
                    <span class="ui-value">Expand siblings</span>
                </div>
                <div class="ui-row no-margin">
                    <span class="ui-label">Home / End</span>
                    <span class="ui-value">First / Last</span>
                </div>
            </section>
        </aside>
    </div>

    <div class="example-info" id="example-info">
        <div class="example-info__left">
            <span class="example-info__stat">
                <strong id="info-progress">0%</strong>
            </span>
            <span class="example-info__stat">
                <span id="info-velocity">0.00</span> /
                <strong id="info-velocity-avg">0.00</strong>
                <span class="example-info__unit">px/ms</span>
            </span>
            <span class="example-info__stat">
                <span id="info-dom">0</span> /
                <strong id="info-total">0</strong>
                <span class="example-info__unit">items</span>
            </span>
        </div>
    </div>
</div>