Recipes
Worked solutions to things people actually need to build.
Contents
- A file browser
- A project portfolio with budgets and totals
- Server-side paging with lazy loading
- An editable grid that saves to an API
- A right-click context menu
- Reordering rows by dragging
- Remembering the user's view
- Master/detail layout
- An export toolbar
- Colour-coded status badges
- Grouping with a summary line
- Filtering from outside the grid
- A 100,000-row grid
- Reacting to a theme toggle
A file browser
Icons, sizes, dates, and a leaf glyph so files and folders read differently.
await grid.setData(
[
{ id: 'name', title: 'Name', tree: true, width: '340px' },
{ id: 'size', title: 'Size', kind: 'number', align: 'right', width: '110px', emptyText: '—' },
{ id: 'modified', title: 'Modified', kind: 'date', width: '150px' },
{ id: 'kind', title: 'Kind', kind: 'badge', width: '120px' },
],
[
{
id: '/src',
icon: '📁',
cells: { name: 'src', modified: '2026-02-11', kind: 'Folder' },
children: [
{ id: '/src/index.ts', icon: '📄',
cells: { name: 'index.ts', size: 4210, modified: '2026-02-14', kind: 'TypeScript' } },
],
},
],
);
grid.leafIcon = '·'; // keeps leaf rows aligned with folder rows
grid.addEventListener('tlvRowDoubleClick', event => {
const node = event.detail;
if (!node.children?.length && !node.hasChildren) openFile(node.id);
});
A project portfolio with budgets and totals
<bm-treelistview
id="grid"
mode="list"
show-footer="true"
show-totals="true"
show-column-filters="true"
allow-grouping="true"
show-group-panel="true"
selection-mode="checkbox"
theme="auto">
</bm-treelistview>
await grid.setData(
[
{ id: 'name', title: 'Project', tree: true, width: '260px' },
{ id: 'owner', title: 'Owner', kind: 'avatar', width: '200px' },
{ id: 'region', title: 'Region', kind: 'badge', width: '110px' },
{ id: 'budget', title: 'Budget', kind: 'currency', align: 'right',
width: '140px', aggregate: 'sum' },
{ id: 'spent', title: 'Spent', kind: 'currency', align: 'right',
width: '140px', aggregate: 'sum' },
{ id: 'progress', title: 'Progress', kind: 'progress', width: '160px', aggregate: 'avg' },
{ id: 'due', title: 'Due', kind: 'date', width: '130px' },
],
projects,
);
await grid.setGroupBy([{ columnId: 'region' }]);
Each region header now shows its own budget total and average progress, and a sticky totals row at the bottom shows the same across everything visible.
Server-side paging with lazy loading
Fetch children only when a branch is opened.
const columns = [
{ id: 'name', title: 'Department', tree: true, width: '300px' },
{ id: 'head', title: 'Head', width: '180px' },
{ id: 'staff', title: 'Staff', kind: 'number', align: 'right', width: '100px' },
];
// Roots only. hasChildren tells the grid to show an expander.
const roots = (await api.departments({ parentId: null })).map(row => ({
id: row.id,
cells: { name: row.name, head: row.head, staff: row.staff },
hasChildren: row.childCount > 0,
}));
await grid.setData(columns, roots);
grid.addEventListener('tlvLazyLoad', async event => {
const node = event.detail;
grid.loading = true;
try {
const rows = await api.departments({ parentId: node.id });
await grid.updateNodeChildren(
node.id,
rows.map(row => ({
id: row.id,
cells: { name: row.name, head: row.head, staff: row.staff },
hasChildren: row.childCount > 0,
})),
);
} catch (error) {
grid.errorText = `Could not load ${node.cells.name}.`;
} finally {
grid.loading = false;
}
});
A branch that returns zero children is marked loaded and will not re-request.
An editable grid that saves to an API
await grid.setData(
[
{ id: 'name', title: 'Task', tree: true, width: '300px', editable: true },
{
id: 'status', title: 'Status', kind: 'badge', width: '150px', editable: true,
editorOptions: [
{ value: 'todo', label: 'To do' },
{ value: 'active', label: 'In progress' },
{ value: 'done', label: 'Done' },
],
},
{
id: 'estimate', title: 'Estimate', kind: 'number', align: 'right',
width: '120px', editable: true,
validate: ({ value }) => {
if (value === null) return 'Enter a number of hours.';
if (value < 0) return 'Estimate cannot be negative.';
if (value > 200) return 'Estimates over 200 hours need approval.';
return true;
},
},
{ id: 'due', title: 'Due', kind: 'date', width: '140px', editable: true },
],
tasks,
);
grid.editable = true;
grid.addEventListener('tlvCellEditCommit', async event => {
const { node, column, value, previousValue } = event.detail;
try {
await api.patchTask(node.id, { [column.id]: value });
} catch {
// The grid already applied the change optimistically - roll it back.
await grid.updateNode(node.id, { [column.id]: previousValue });
showToast('Could not save that change.');
}
});
Users open an editor by double-clicking, or by pressing F2 or Enter. Tab commits and moves on.
A right-click context menu
The component suppresses the browser menu and hands you the coordinates.
grid.addEventListener('tlvContextMenu', event => {
const { node, x, y, column } = event.detail;
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.dataset.nodeId = node.id;
menu.dataset.columnId = column?.id ?? '';
menu.hidden = false;
});
document.addEventListener('click', () => (menu.hidden = true));
column tells you which cell was clicked, so you can offer column-specific commands such as
"Filter by this value".
Reordering rows by dragging
The grid reports the intent; you perform the move.
<bm-treelistview allow-drag-drop="true"></bm-treelistview>
let items = initialItems;
grid.addEventListener('tlvNodeDrop', async event => {
const { draggedNode, targetNode, position } = event.detail;
items = moveNode(items, draggedNode.id, targetNode.id, position);
// preserveExpansion keeps the user's open branches open across the move.
await grid.setItemsData(items, true);
await api.reorder(draggedNode.id, targetNode.id, position);
});
/** Remove a node from wherever it is and reinsert it relative to a target. */
function moveNode(tree, draggedId, targetId, position) {
let dragged;
const remove = nodes =>
nodes.filter(node => {
if (node.id === draggedId) { dragged = node; return false; }
if (node.children) node.children = remove(node.children);
return true;
});
const insert = nodes =>
nodes.flatMap(node => {
if (node.id === targetId) {
if (position === 'inside') {
return [{ ...node, children: [...(node.children ?? []), dragged] }];
}
return position === 'before' ? [dragged, node] : [node, dragged];
}
return [{ ...node, children: node.children ? insert(node.children) : node.children }];
});
return insert(remove(structuredClone(tree)));
}
The drop position comes from where the pointer is in the row — top quarter before, bottom quarter after, middle inside — and a live indicator shows it before the user commits.
Remembering the user's view
The built-in way:
<bm-treelistview persist-state="true" state-storage-key="projects-grid"></bm-treelistview>
Server-side instead, so the view follows the user between devices:
grid.addEventListener('tlvStateChange', debounce(event => {
api.saveGridState('projects', event.detail);
}, 800));
const saved = await api.loadGridState('projects');
if (saved) await grid.restoreState(saved);
A snapshot holds only what the user changed, so restoring one into a grid whose columns have since changed is safe: unknown ids are skipped and new columns appear.
Master/detail layout
<div class="split">
<bm-treelistview id="grid" selection-mode="single" selection-follows-focus="true"></bm-treelistview>
<section id="detail"></section>
</div>
grid.addEventListener('tlvSelectionChange', event => {
const node = event.detail; // single mode gives one node
renderDetail(node && node.id ? node : null);
});
selection-follows-focus (the default) means arrow keys move the detail pane too, which is what a
master/detail layout wants. Turn it off for multi-select grids.
An export toolbar
<bm-treelistview id="grid" show-toolbar="true" selection-mode="checkbox">
<div slot="toolbar-right">
<button data-format="csv">CSV</button>
<button data-format="excel">Excel</button>
<button data-format="json">JSON</button>
<button id="copy">Copy selection</button>
</div>
</bm-treelistview>
document.querySelectorAll('[data-format]').forEach(button => {
button.addEventListener('click', () =>
grid.downloadData(
{ format: button.dataset.format, selectedOnly: hasSelection() },
`projects.${button.dataset.format === 'excel' ? 'xls' : button.dataset.format}`,
),
);
});
document.getElementById('copy').addEventListener('click', async () => {
const copied = await grid.copyToClipboardAsText(true);
showToast(copied ? 'Copied' : 'Clipboard unavailable');
});
Add raw: true when the file is going into another system rather than to a person.
Colour-coded status badges
No JavaScript needed — the badge writes its value to data-value.
{ id: 'status', title: 'Status', kind: 'badge', width: '140px' }
bm-treelistview::part(badge)[data-value='Blocked'] {
background: #fee2e2;
color: #991b1b;
}
bm-treelistview::part(badge)[data-value='Shipped'] {
background: #dcfce7;
color: #166534;
}
For anything more elaborate, use a custom renderer:
grid.cellRenderers = {
status: ({ value }) => {
const span = document.createElement('span');
span.className = `status status-${String(value).toLowerCase()}`;
span.textContent = String(value);
return span;
},
};
Grouping with a summary line
await grid.setGroupBy([{ columnId: 'region' }]);
Each group header shows its count and every aggregate its columns declare. To restyle:
bm-treelistview::part(group-row) {
background: #0f172a;
color: #e2e8f0;
}
bm-treelistview::part(group-aggregates) {
font-variant-numeric: tabular-nums;
}
Collapse everything at once:
await grid.collapseAllGroups();
Filtering from outside the grid
<input id="search" type="search" placeholder="Search projects">
<select id="region"><option value="">All regions</option>…</select>
search.addEventListener('input', debounce(() => grid.setFilterText(search.value), 150));
region.addEventListener('change', () => {
grid.setColumnFilters(
region.value ? [{ columnId: 'region', operator: 'equals', value: region.value }] : [],
);
});
The two layers combine with AND, so a search term and a region filter narrow together.
Debouncing the text input is worth it above a few thousand rows: each keystroke re-runs the filter.
A 100,000-row grid
<bm-treelistview
id="grid"
mode="list"
virtualize="true"
row-height="32"
density="compact"
overscan="10"
show-footer="true">
</bm-treelistview>
await grid.setData(columns, hugeDataset);
// Jump straight to a row, wherever it is.
await grid.scrollToNode('row-84210', 'center');
Match row-height to the density (32 for compact, 38 comfortable, 48 spacious) or the spacers will not
line up. See Performance.
Reacting to a theme toggle
const media = matchMedia('(prefers-color-scheme: dark)');
// Or simply: grid.theme = 'auto';
function apply() {
grid.theme = media.matches ? 'dark' : 'light';
}
media.addEventListener('change', apply);
apply();
theme="auto" does this for you. Set the theme explicitly only when your application has its own
light/dark switch that should override the operating system.