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">→</span>
<span class="ui-value">Expand</span>
</div>
<div class="ui-row no-margin">
<span class="ui-label">←</span>
<span class="ui-value">Collapse / parent</span>
</div>
<div class="ui-row no-margin">
<span class="ui-label">↑ / ↓</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>