TreeListView API Guide
Complete reference for the bm-treelistview Web Component.
Version 0.8.0 · What's new in 0.2 · Getting started · Recipes · Theming · Accessibility · Performance
Contents
- Commercial distribution model
- Quick start
- Supplying data
- Column model
- Cell kinds
- Node model
- Row actions
- Properties
- Methods
- Events
- Selection
- Column sizing and keyboard control
- Sorting
- Filtering
- Grouping and aggregation
- Inline editing
- Clipboard paste
- Undo and redo
- Cell navigation and range selection
- Pinned rows
- Truncation tooltips
- Conditional formatting
- Skeleton loading
- Localisation
- Printing
- Export and clipboard
- Server-side data source
- Paging
- Column virtualisation
- Virtual scrolling
- Lazy loading
- Drag and drop
- State persistence
- Custom cell renderers
- Slots
- CSS parts
- TypeScript types
- See also
Commercial distribution model
This component is proprietary commercial software owned by Sundaranarayanan Subramaniam / Binarymission (UK).
Customers receive only the compiled commercial runtime:
bm-treelistview.min.js
The public marketing site and playground must use the separate demo runtime:
bm-treelistview.demo.js
The demo runtime is limited to 500 loaded nodes and paints a "TreeListView Demo" badge. The commercial runtime has neither.
The source, Stencil project and build scripts are not distributed.
Use of the compiled runtime is permitted only under a written commercial licence agreement with Binarymission (UK).
Quick start
<script src="./bm-treelistview.min.js"></script>
<bm-treelistview
id="grid"
mode="tree"
theme="light"
density="comfortable"
selection-mode="checkbox"
show-filter="true"
show-footer="true"
zebra="true"
></bm-treelistview>
<script>
customElements.whenDefined('bm-treelistview').then(async () => {
const grid = document.getElementById('grid');
await grid.ready();
await grid.setData(
[
{ id: 'name', title: 'Project', tree: true, width: '260px' },
{ id: 'owner', title: 'Owner', width: '160px' },
{ id: 'budget', title: 'Budget', kind: 'currency', align: 'right', width: '140px' },
{ id: 'due', title: 'Due', kind: 'date', width: '140px' },
],
[
{
id: 'p1',
cells: { name: 'Platform', owner: 'Priya', budget: 120000, due: '2026-03-01' },
expanded: true,
children: [
{ id: 'p1-a', cells: { name: 'API', owner: 'Ade', budget: 45000, due: '2026-01-15' } },
],
},
],
);
grid.addEventListener('tlvSelectionChange', event => {
console.log('selected', event.detail);
});
});
</script>
The element needs a height. It is
display: blockwithheight: 100%, so it fills its parent. Give the parent a height, or set one on the element:<bm-treelistview style="height: 520px">.
Supplying data
There are three ways in, and they can be mixed.
1. setData() — recommended
await grid.setData(columns, items);
Applies both in one update, so the grid renders once rather than twice.
2. Properties
grid.columns = columns;
grid.items = items;
3. Attributes or inline JSON — no JavaScript at all
<bm-treelistview columns='[{"id":"name","title":"Name"}]'
items='[{"id":"1","cells":{"name":"Alpha"}}]'></bm-treelistview>
<bm-treelistview>
<script type="application/json">
{
"columns": [{ "id": "name", "title": "Name" }],
"items": [{ "id": "1", "cells": { "name": "Alpha" } }]
}
</script>
</bm-treelistview>
Inline JSON is read once, when the component first loads, and only when the columns/items properties are empty.
Malformed JSON never throws. It logs a console.warn and is treated as absent.
Column model
interface TlvColumn {
id: string; // unique key; also the lookup key into cells
title: string; // header text
// Layout
width?: string; // default '180px'
minWidth?: string; // default '80px'
maxWidth?: string;
align?: 'left' | 'center' | 'right';
pinned?: 'none' | 'left' | 'right';
hidden?: boolean;
headerGroup?: string; // band title in a two-row header (0.3)
// Behaviour
tree?: boolean; // carries the expander and indentation
sortable?: boolean; // default true
filterable?: boolean; // default true
resizable?: boolean; // default true
reorderable?: boolean; // default true (0.2)
groupable?: boolean; // default true (0.2)
exportable?: boolean; // default true (0.2)
// Presentation
kind?: TlvCellKind; // default 'text'
valueType?: 'text' | 'number' | 'date' | 'boolean'; // inferred from kind (0.2)
precision?: number; // decimal places (0.2)
currency?: string; // per-column override (0.2)
emptyText?: string; // shown for blanks (0.2)
cellClass?: string; // extra class on cells (0.2)
headerTooltip?: string; // (0.2)
// Aggregation
aggregate?: TlvAggregate; // (0.2)
// Editing
editable?: boolean; // (0.2)
editor?: TlvEditorKind; // inferred from kind (0.2)
editorOptions?: TlvEditorOption[]; // (0.2)
validate?: (ctx: TlvEditContext) => true | string | void; // (0.2)
}
Only id and title are required:
[{ id: 'name', title: 'Name' }, { id: 'owner', title: 'Owner' }]
valueType — why it matters
valueType decides how a column sorts and filters. It is inferred from kind:
kind |
inferred valueType |
|---|---|
currency, progress, number, percent, rating |
number |
date |
date |
boolean |
boolean |
| everything else | text |
Set it explicitly when the kind and the data disagree — for example a badge column whose values are really numbers:
{ id: 'priority', title: 'Priority', kind: 'badge', valueType: 'number' }
This is the single biggest behavioural fix in 0.2. In 0.1 every column sorted as text, so a Budget column ordered
100, 1000, 25, 3000. Nothing in your column definitions needs to change — the inference handles it.
Banded headers (0.3)
Give columns a shared headerGroup and they are drawn under one spanning cell in a second header row:
const columns = [
{ id: 'team', title: 'Team', tree: true },
{ id: 'q1', title: 'Q1', kind: 'currency', headerGroup: 'Revenue 2026' },
{ id: 'q2', title: 'Q2', kind: 'currency', headerGroup: 'Revenue 2026' },
{ id: 'heads', title: 'Headcount', kind: 'number', headerGroup: 'People' },
{ id: 'lead', title: 'Lead', headerGroup: 'People' },
{ id: 'region', title: 'Region' }, // no band — sits under empty filler
];
The band row appears only when at least one column declares a headerGroup; otherwise the header is
the single row it has always been.
A band is a contiguous run, not a lookup. If the user drags Region between Q1 and Q2, the
grid draws two Revenue 2026 bands rather than one cell spanning three columns. A band that
spanned the gap would be asserting an adjacency the grid no longer has — two bands show the user
exactly what their reordering did. The same applies when a column in the middle of a band is hidden:
the run closes up and the band becomes one cell again.
Band widths are computed as the sum of the widths of the columns they cover, so they stay aligned through resizing, pinning, auto-fit and reordering.
For assistive technology the band row is row 1 with aria-colspan on each titled cell, the header row
becomes row 2, and every data row's aria-rowindex shifts down by one. Untitled filler cells are
role="presentation" — announcing a blank header above every ungrouped column is noise, not
information.
Style the band with --tlv-band-bg, --tlv-band-fg and --tlv-band-height, or the band-cell and
band-title CSS parts.
Cell kinds
| Kind | Renders | Value shape |
|---|---|---|
text |
plain text (default) | anything |
iconText |
plain text; pair with node.icon |
anything |
badge |
pill; value also written to data-value for CSS targeting |
string |
boolean |
✓ / ✕ with a screen-reader label | boolean-ish |
link |
<a target="_blank" rel="noopener noreferrer"> |
URL string |
date |
locale-formatted date | ISO string, Date, epoch ms |
currency |
locale-formatted currency | number or numeric string |
number |
locale-formatted number | number or numeric string |
percent |
percentage on a 0–100 scale | number |
progress |
progress bar, colour-banded by completion | number 0–100 |
rating |
five stars | number 0–5 |
chips |
row of pills | array, or comma-separated string |
sparkline |
inline SVG trend line | array of numbers |
avatar |
image or auto-coloured initials + name | string, or { name, src } |
actions |
buttons from the rowActions property |
(no value) |
New in 0.2: number, percent, chips, rating, sparkline, avatar.
{ id: 'team', title: 'Team', kind: 'chips' } // ['Design', 'Research']
{ id: 'trend', title: 'Trend', kind: 'sparkline' } // [4, 9, 7, 12, 18]
{ id: 'owner', title: 'Owner', kind: 'avatar' } // { name: 'Priya Nair' }
{ id: 'quality', title: 'Quality', kind: 'rating' } // 4
An actions column holds no data. It is excluded from sorting, filtering and every export format.
Node model
interface TlvNode {
id: string; // stable and unique
cells: Record<string, unknown> | unknown[];
children?: TlvNode[];
hasChildren?: boolean; // "there are children I haven't fetched"
childrenLoaded?: boolean; // set by updateNodeChildren()
expanded?: boolean; // read on load and on setData()
selectable?: boolean; // false = visible but not selectable
disabled?: boolean; // greyed out; no selection, expansion, edit or drag
draggable?: boolean; // false = this row cannot be dragged
icon?: string; // glyph before the tree cell text
variant?: string; // written to data-node-variant
rowClass?: string; // extra class on the row (0.2)
tooltip?: string; // row title attribute (0.2)
}
cells: keyed or positional
// Keyed - recommended
{ id: '1', cells: { name: 'Platform', owner: 'Priya' } }
// Positional - matches column definition order
{ id: '1', cells: ['Platform', 'Priya'] }
Keyed objects are strongly preferred. Positional arrays are indexed against the original column definition order, so they survive hiding and reordering columns — but they break silently the moment someone edits the column list.
Row actions
Define a column with kind: 'actions', then supply the buttons:
grid.rowActions = [
{ id: 'edit', text: 'Edit', icon: '✎' },
{ id: 'delete', text: 'Delete', icon: '🗑', variant: 'danger',
visibleFor: node => !node.disabled,
disabledFor: node => node.cells.locked === true },
];
// ...and a column to host them
{ id: 'actions', title: '', kind: 'actions', width: '160px', sortable: false }
grid.addEventListener('tlvActionClick', event => {
const { action, node } = event.detail;
});
visibleFor and disabledFor are new in 0.2.
Action buttons are outside the tab order by default — fifty rows would otherwise add 150 tab stops
between the grid and whatever follows it. Set row-actions-tabbable to include them.
Properties
Attribute names are the kebab-case form of the property name: selectionMode → selection-mode.
Data
| Property | Type | Default | Notes |
|---|---|---|---|
columns |
TlvColumn[] | string |
[] |
array or JSON string |
items |
TlvNode[] | string |
[] |
array or JSON string |
rowActions |
TlvRowAction[] | string |
[] |
|
cellRenderers |
Record<string, TlvCellRenderer> |
— | property only |
Appearance
| Property | Type | Default |
|---|---|---|
mode |
'tree' | 'list' |
'tree' |
theme |
see themes | 'light' |
density |
'comfortable' | 'compact' | 'spacious' |
'comfortable' |
zebra |
boolean |
false |
componentShadow |
boolean |
true |
rowHoverShadow |
boolean |
false |
direction |
'auto' | 'ltr' | 'rtl' |
'auto' (0.2) |
Chrome
| Property | Type | Default | |
|---|---|---|---|
showHeader |
boolean |
true |
|
showFilter |
boolean |
false |
global search box |
showToolbar |
boolean |
false |
exposes the toolbar slots |
showFooter |
boolean |
false |
status bar |
showSelectionCount |
boolean |
true |
|
showColumnFilters |
boolean |
false |
per-column filter row (0.2) |
showGroupPanel |
boolean |
false |
group-by drop target (0.2) |
showTotals |
boolean |
false |
footer totals row (0.2) |
showColumnMenu |
boolean |
false |
column chooser (0.2) |
stickyHeader |
boolean |
true |
(0.2) |
Behaviour
| Property | Type | Default | |
|---|---|---|---|
selectionMode |
'none' | 'single' | 'multiple' | 'checkbox' |
'single' |
|
selectedId |
string |
— | two-way |
selectedIds |
string[] | string |
[] |
two-way |
cascadeSelection |
boolean |
false |
tick a parent, tick its subtree (0.3) |
expandOnRowClick |
boolean |
false |
|
treeColumnId |
string |
— | defaults to the first column |
freezeTreeColumn |
boolean |
false |
|
allowSorting |
boolean |
true |
|
allowFiltering |
boolean |
true |
|
allowColumnResize |
boolean |
true |
|
allowContextMenu |
boolean |
true |
|
allowDragDrop |
boolean |
false |
|
allowColumnReorder |
boolean |
false |
(0.2) |
allowGrouping |
boolean |
false |
(0.2) |
allowMultiSort |
boolean |
true |
Shift-click (0.2) |
allowClipboard |
boolean |
true |
Ctrl/Cmd+C (0.2) |
allowTypeAhead |
boolean |
true |
(0.2) |
editable |
boolean |
false |
master switch (0.2) |
allowPaste |
boolean |
false |
multi-cell Ctrl/Cmd+V (0.5) |
allowUndo |
boolean |
false |
edit history (0.5) |
cellNavigation |
boolean |
false |
focus by cell, not row (0.6) |
allowCellRangeSelection |
boolean |
false |
rectangular cell selection (0.6) |
truncationTooltips |
boolean |
true |
tooltip on clipped cells (0.6) |
conditionalFormats |
TlvConditionalFormat[] | string |
[] |
value-driven styling (0.7) |
skeletonRows |
number |
0 |
placeholder rows while loading (0.7) |
messages |
Partial<TlvMessages> | string |
— | translations (0.7) |
printAllRows |
boolean |
true |
render everything when printing (0.7) |
dataSource |
TlvDataSource |
— | fetch rows from the server (0.8) |
dataSourceBlockSize |
number |
100 |
rows per request (0.8) |
dataSourceDebounce |
number |
250 |
ms before re-querying (0.8) |
pageSize |
number |
0 |
0 means no paging; two-way (0.8) |
page |
number |
0 |
two-way (0.8) |
virtualizeColumns |
boolean |
false |
render only visible columns (0.8) |
undoLimit |
number |
100 |
steps retained (0.5) |
validateRow |
(context) => true | string | Promise<…> |
— | cross-field rule (0.5) |
highlightMatches |
boolean |
true |
(0.2) |
selectionFollowsFocus |
boolean |
true |
(0.2) |
rowActionsTabbable |
boolean |
false |
|
parentRollUp |
'off' | 'whenEmpty' | 'always' |
'off' |
parents show aggregates of their leaves (0.3) |
selectionFollowsFocus reproduces 0.1 behaviour: arrow keys change the selection as well as the focus.
Set it to false for multiple selection, where a user needs to move past rows without selecting them.
Virtualisation
| Property | Type | Default | |
|---|---|---|---|
virtualize |
boolean |
false |
|
virtualizeThreshold |
number |
0 |
auto-enable above N rows; 0 = off (0.2) |
rowHeight |
number |
38 |
must match the rendered height |
overscan |
number |
8 |
rows rendered beyond each edge |
Sorting, filtering, grouping
| Property | Type | Default | |
|---|---|---|---|
filterText |
string |
'' |
two-way |
sortColumnId |
string |
— | two-way; mirrors sortModel[0] |
sortDirection |
'asc' | 'desc' | 'none' |
'none' |
two-way |
sortModel |
TlvSortDescriptor[] | string |
[] |
(0.2) |
columnFilters |
TlvColumnFilter[] | string |
[] |
(0.2) |
groupBy |
TlvGroupDescriptor[] | string |
[] |
(0.2) |
Formatting, status and labels
| Property | Type | Default |
|---|---|---|
locale |
string |
'en-GB' |
currency |
string |
'GBP' |
loading |
boolean |
false |
errorText |
string |
'' |
expandIcon / collapseIcon / leafIcon |
string |
'▸' / '▾' / '' |
accessibleLabel |
string |
'Tree list view' |
announceChanges |
boolean |
true |
selectAllLabel |
string |
'Select all visible rows' |
rowCheckboxLabel |
string |
'Select row' |
footerVisibleLabel |
string |
'Visible rows' |
footerSelectedLabel |
string |
'Selected' |
totalsLabel |
string |
'Totals' (0.2) |
filterPlaceholder |
string |
'Filter...' (0.2) |
emptyText |
string |
'No records to display.' (0.2) |
loadingText |
string |
'Loading...' (0.2) |
groupPanelText |
string |
(drag prompt) (0.2) |
Persistence
| Property | Type | Default |
|---|---|---|
persistState |
boolean |
false |
stateStorageKey |
string |
'tlv-treelistview-state' |
Themes
Light: light · ocean · forest · sunset · grape · slate · executive · amber · neon ·
mint (0.11)
Dark: dark · aurora (0.11) · royal (0.11) · ocean-dark · sunset-dark · forest-dark ·
slate-dark · amber-dark (all 0.11)
Or auto (0.2), which follows the operating system. The -dark names are dark readings of the
light themes beside them — see Theming.
auto follows the operating system's light/dark setting via prefers-color-scheme.
Methods
Every method is async — Stencil requires it, because the implementation may not have downloaded yet.
Await
ready()before calling anything on a freshly created element.await customElements.whenDefined('bm-treelistview'); await grid.ready(); await grid.setData(columns, items);Do not use Stencil's
componentOnReady(). It exists only in the lazydistbuild; the single-file runtime you ship (bm-treelistview.min.js) is the custom-elements build, where it is undefined.ready()is defined by the component itself, so it is present in every build.
Data
| Method | Returns | |
|---|---|---|
ready() |
void |
resolves after the first render — await this first (0.2) |
setData(columns, items) |
void |
replace both in one update |
setItemsData(items, preserveExpansion?) |
void |
replace rows only (0.2) |
updateNode(nodeId, cells) |
void |
merge cell values into one row (0.2) |
updateNodeChildren(nodeId, children) |
void |
attach lazily-loaded children |
refresh() |
void |
force a recompute after in-place mutation (0.2) |
getVisibleData() |
TlvNode[] |
visible rows, in display order |
getRowCount() |
number |
rows produced, including group headers (0.2) |
Expansion
| Method | Returns | |
|---|---|---|
expandAll() / collapseAll() |
void |
|
expandToNode(nodeId, scroll?) |
boolean |
open every ancestor, then scroll to it (0.2) |
expandAllGroups() / collapseAllGroups() |
void |
(0.2) |
Selection
| Method | Returns | |
|---|---|---|
selectNodeById(nodeId) |
void |
|
selectAll() |
void |
every selectable visible row (0.2) |
clearSelection() |
void |
|
getSelectedNodes() |
TlvNode[] |
in visible order (0.2) |
Columns
| Method | Returns | |
|---|---|---|
setColumnVisible(columnId, visible) |
void |
|
resetColumnWidths() |
void |
|
getColumnOrder() |
string[] |
(0.2) |
setColumnOrder(order) |
void |
(0.2) |
moveColumn(columnId, toIndex) |
void |
(0.2) |
autoFitColumn(columnId) |
void |
fit one column to its contents (0.3) |
autoFitColumns() |
void |
fit every visible column (0.3) |
Sorting, filtering, grouping
| Method | Returns | |
|---|---|---|
setFilterText(value) |
void |
|
setSortModel(model) |
void |
(0.2) |
setColumnFilters(filters) |
void |
(0.2) |
setGroupBy(groups) |
void |
pass [] to ungroup (0.2) |
Editing
| Method | Returns | |
|---|---|---|
beginEdit(nodeId, columnId) |
boolean |
(0.2) |
pasteFromClipboard() |
number |
cells written (0.5) |
pasteFromText(text, anchor?) |
number |
cells written (0.5) |
undo() / redo() |
boolean |
(0.5) |
getHistoryState() |
{ canUndo, canRedo } |
(0.5) |
clearHistory() |
void |
(0.5) |
focusCellAt(nodeId, columnId, extend?) |
void |
(0.6) |
getCellRange() |
{ range?, cells } |
(0.6) |
copyCellRange() |
boolean |
(0.6) |
clearCellRange() |
void |
(0.6) |
goToPage(page) |
number |
the page landed on (0.8) |
getPageState() |
{ page, pageCount, pageSize } |
(0.8) |
commitEdit() |
boolean |
(0.2) |
cancelEdit() |
void |
(0.2) |
Export and clipboard
| Method | Returns | |
|---|---|---|
exportData(options?) |
string |
(0.2) |
downloadData(options?, filename?) |
void |
(0.2) |
copyToClipboardAsText(selectedOnly?) |
boolean |
TSV (0.2) |
getVisibleDataAsCsv() |
string |
0.1 compatibility |
downloadVisibleDataAsCsv(filename?) |
void |
0.1 compatibility |
Scrolling
| Method | Returns | |
|---|---|---|
scrollToIndex(index, align?) |
void |
(0.2) |
scrollToNode(nodeId, align?) |
void |
(0.2) |
align is 'auto' | 'start' | 'center' | 'end'. 'auto' does nothing if the row is already visible.
State
| Method | Returns | |
|---|---|---|
getState() |
TlvStateSnapshot |
|
restoreState(snapshot) |
void |
|
clearPersistedState() |
void |
(0.2) |
Events
All events bubble and are composed, so you can listen on an ancestor.
| Event | detail |
|
|---|---|---|
tlvSelectionChange |
TlvNode in single mode, TlvNode[] otherwise |
|
tlvNodeExpand |
TlvNode |
|
tlvNodeCollapse |
TlvNode |
|
tlvLazyLoad |
TlvNode |
respond with updateNodeChildren() |
tlvSortChange |
{ columnId?, direction, sortModel? } |
|
tlvFilterChange |
string |
|
tlvColumnResize |
{ columnId, width } |
|
tlvContextMenu |
{ node, x, y, column? } |
column added in 0.2 |
tlvActionClick |
{ action, node } |
|
tlvRowDoubleClick |
TlvNode |
also fires on Enter |
tlvStateChange |
TlvStateSnapshot |
fires whether or not persistState is on |
tlvNodeDrop |
{ draggedNode, targetNode, position } |
you perform the move |
tlvColumnReorder |
{ columnId, fromIndex, toIndex, columnOrder } |
(0.2) |
tlvColumnFilterChange |
{ columnFilters } |
(0.2) |
tlvGroupChange |
{ groupBy } |
(0.2) |
tlvCellEditStart |
{ node, column, value } |
(0.2) |
tlvCellEditCommit |
{ node, column, previousValue, value } |
(0.2) |
tlvCellEditCancel |
{ node, column, value, reason? } |
(0.2) |
tlvActiveRowChange |
{ node?, index } |
keyboard focus moved (0.2) |
tlvRowsRendered |
{ startIndex, endIndex, total } |
(0.2) |
tlvCopy |
{ text, rowCount } |
(0.2) |
tlvPaste |
{ changes, skipped, clipped } |
(0.5) |
tlvHistoryChange |
{ canUndo, canRedo, reason, label? } |
(0.5) |
tlvActiveCellChange |
{ nodeId?, columnId? } |
(0.6) |
tlvCellRangeChange |
{ range?, rows, columns } |
(0.6) |
tlvPageChange |
{ page, pageCount, pageSize } |
(0.8) |
tlvDataSourceError |
{ request, error } |
(0.8) |
tlvDetailToggle |
{ node, open } |
(0.9) |
tlvDetailAttach |
{ node, container } |
once per opening (0.9) |
tlvViewsChange |
{ views } |
(0.9) |
Selection
<bm-treelistview selection-mode="checkbox" cascade-selection="true"></bm-treelistview>
Four modes:
selectionMode |
Behaviour |
|---|---|
none |
rows are focusable but never selected |
single |
one row at a time; selectedId is the two-way property |
multiple |
Ctrl/Cmd-click adds, Shift-click extends a range; selectedIds is two-way |
checkbox |
a checkbox column plus a select-all box in the header |
A node opts out with selectable: false. Unselectable rows are skipped by selectAll(), by
Shift-click ranges, and by the cascade below.
Tri-state checkbox cascade (0.3)
With cascade-selection="true", ticking a parent ticks every selectable descendant, and unticking it
clears them. A parent whose descendants are only partly ticked renders in the indeterminate state
(aria-checked="mixed"), so a glance at the top of the tree tells you whether a branch is fully in,
partly in, or out.
grid.selectionMode = 'checkbox';
grid.cascadeSelection = true;
grid.addEventListener('tlvSelectionChange', e => console.log(e.detail.selectedIds));
The parent's state is derived on every paint from the descendants that are currently in the data, not stored alongside the selection. That is the important design decision: a stored parent flag drifts out of step the moment a filter hides half a branch, a lazy-loaded child arrives, or the application replaces the data. Deriving it means the checkbox always answers the question "are all of my selectable descendants ticked right now?" and can never disagree with what is on screen.
Consequences worth knowing:
selectedIdscontains parents only when their whole subtree is selected. An indeterminate parent is not in the array — it has no selection of its own to report. Read the leaves, not the branches.- Descendants with
selectable: falseare ignored on both sides. They are never added by a cascade, and they are not counted when deciding whether a parent is "all selected". A branch of read-only rows therefore does not hold its parent permanently indeterminate. - A parent with no selectable descendants behaves like an ordinary row — it ticks and unticks itself.
selectAll()cascades too, so select-all followed by unticking one leaf leaves that leaf's ancestors indeterminate rather than ticked.
Leave cascadeSelection at false (the default) and 0.1/0.2 behaviour is unchanged: each checkbox
selects exactly its own row.
Column sizing and keyboard control
Auto-fit (0.3)
await grid.autoFitColumn('note'); // one column
await grid.autoFitColumns(); // every visible column
Users get it by double-clicking a column's resize handle, the gesture every spreadsheet already uses.
Auto-fit measures what is painted, not what is in the data: the header cell and each rendered body
cell are asked how wide their contents want to be, and the widest wins. That is what makes it correct
for indented tree cells, avatars, chips and star ratings, none of which have a width you could derive
from a value. Cells clip with text-overflow: ellipsis, so a truncated cell still reports its full
natural width.
minWidth and maxWidth are honoured, identically to a drag — a column that cannot be dragged below
120px is not auto-fitted to 60px either. Each fitted column emits tlvColumnResize, so persistence
and your own listeners cannot tell auto-fit from a drag.
Only rendered rows are measured. With virtual scrolling on, rows outside the viewport have no DOM to measure, so auto-fit fits what is on screen — the same thing a spreadsheet does. Call it again after scrolling if you want a different sample, or
expandAll()first on a small grid.
Keyboard column control (0.3)
The header carries a roving tab stop of its own: one Tab reaches it, and the arrow keys walk it from there. Giving every header cell its own tab stop would put a dozen stops between the toolbar and the first row.
| Key | Action |
|---|---|
| ← → | Move between header cells (mirrored in RTL) |
| Home / End | First / last column |
| Enter or Space | Sort — Shift adds a tie-breaker |
| Ctrl/Cmd+Shift+←/→ | Move the column |
| Ctrl/Cmd+Shift+G | Group / ungroup by the column |
Reordering and grouping were drag-only until 0.3, which put both out of reach for anyone not using a
mouse. Each action is announced politely, and refusals are announced too — a column with
reorderable: false says so rather than silently doing nothing.
Sorting from the keyboard runs the same code path as clicking the header, so the two cannot drift apart.
Sorting
Clicking a header cycles none → ascending → descending → none.
Comparison is type-aware (see valueType) and stable — equal rows
keep their relative order. Blank values always sort last, in both directions.
Multi-column sort
Shift-click a second header to add a tie-breaker. A small numeric badge appears next to each arrow
showing the level. Set allow-multi-sort="false" to disable it.
await grid.setSortModel([
{ columnId: 'region', direction: 'asc' },
{ columnId: 'budget', direction: 'desc' },
]);
sortColumnId and sortDirection always mirror sortModel[0], so 0.1 code that reads them keeps working.
Tree mode vs list mode
- Tree mode sorts each sibling group in place, so children stay under their own parent.
- List mode (and any grouping) flattens first and sorts the flat list, so the order is global.
Filtering
Two independent layers, combined with AND.
Global filter
<bm-treelistview show-filter="true"></bm-treelistview>
await grid.setFilterText('overdue');
Searches every column whose filterable is not false. Arrays and objects are flattened first, so a
chips cell holding ['Design', 'Research'] matches a search for "research".
A node is kept when it matches or any descendant matches, and retained ancestors are auto-expanded
so the match is visible without the user clicking anything. Matching text is wrapped in <mark> unless
highlight-matches="false".
Per-column filters
<bm-treelistview show-column-filters="true"></bm-treelistview>
await grid.setColumnFilters([
{ columnId: 'budget', operator: 'gt', value: 10000 },
{ columnId: 'due', operator: 'between', value: '2026-01-01', value2: '2026-03-31' },
{ columnId: 'owner', operator: 'notEmpty' },
]);
| Operator | Applies to |
|---|---|
contains, notContains, startsWith, endsWith |
text |
equals, notEquals |
all |
gt, gte, lt, lte, between |
number, date |
empty, notEmpty |
all |
Operands are coerced through the column's valueType, so budget > '1000' typed into a text box still
compares numerically. between tolerates its bounds being supplied in either order, and degrades to a
lower bound while the upper one is still blank.
A filter naming a column that no longer exists is ignored rather than hiding everything — persisted state routinely outlives a schema.
Grouping and aggregation
<bm-treelistview allow-grouping="true" show-group-panel="true"></bm-treelistview>
await grid.setGroupBy([{ columnId: 'region' }, { columnId: 'owner' }]);
await grid.setGroupBy([]); // ungroup
With show-group-panel, users drag a column header onto the panel to group by it, and click the ✕ on a
chip to remove it.
Grouping flattens the hierarchy — every node at every depth becomes a candidate row and lands in the bucket its own value calls for. Clearing the grouping brings the tree back intact.
Aggregates
A column opts in with aggregate:
{ id: 'budget', title: 'Budget', kind: 'currency', aggregate: 'sum' }
{ id: 'score', title: 'Score', kind: 'number', aggregate: 'avg' }
{ id: 'health', title: 'Health', aggregate: values => values.filter(Boolean).length }
Built-ins: sum, avg, min, max, count, countDistinct, first, last.
Aggregates appear inline in each group header, and — with show-totals="true" — in a sticky totals row
at the bottom.
Totals cover the rows that are visible, not the whole dataset. A child inside a collapsed parent is not counted, and neither is a row hidden by a filter. That is deliberate: the totals row answers "what am I looking at?", and matches the footer's visible-row count and the select-all-visible behaviour. Call
expandAll()first if you want the totals to cover the entire tree.
Numeric aggregates parse what they can and skip what they cannot, so one 'n/a' in a budget column
yields a slightly smaller total rather than NaN across the whole report. A group with nothing to
report shows a blank rather than 0.
Parent roll-ups (0.3)
Grouping is one way to see subtotals; a hierarchy is another. parentRollUp makes a parent row display
an aggregate of the leaves beneath it, for any column that declares an aggregate.
<bm-treelistview parent-roll-up="whenEmpty"></bm-treelistview>
| Value | Behaviour |
|---|---|
off (default) |
parents show only their own values — 0.2 behaviour |
whenEmpty |
a roll-up fills in a parent that has no value of its own; a deliberate parent-level figure is left alone |
always |
the roll-up wins, for datasets where parents are pure containers |
whenEmpty is the safe choice for real data, where some parents carry a genuine figure (a departmental
budget that is not simply the sum of its projects) and others are left blank. always suits
folder-like trees where a parent has no meaning of its own.
Roll-ups aggregate leaves, not descendants. A sum over every descendant would count an intermediate parent's own value and the children it already contains, so a three-level tree would report a total half again too large. Only nodes with no children contribute, which makes the roll-up at any depth the sum of the roll-ups one level below it — the figure adds up however deep the tree goes.
The decision is taken per column, not per row: in the same parent, a budget that the row states for
itself is kept under whenEmpty while a blank score beside it is filled from the leaves.
The totals row is not affected by
parentRollUp. Totals aggregate every visible row's own value, parents included, so with roll-ups on the footer total will normally exceed the root row's roll-up — the root counts leaves once, the footer counts each row that is on screen. If you want the footer to agree with the root, either give parents no value of their own or read the root row.
Rolled-up cells are marked .tlv-cell-rollup — tinted and italic by default, tunable with
--tlv-rollup-fg and --tlv-rollup-bg — so a derived figure never reads as data someone typed. They
are also not editable: a roll-up is a view of the leaves, and the only honest way to change it is to
edit a leaf.
Roll-ups are computed once per data revision alongside the rest of the pipeline, so turning them on does not cost a pass per paint.
Inline editing
<bm-treelistview editable="true"></bm-treelistview>
{
id: 'budget',
title: 'Budget',
kind: 'currency',
editable: true,
validate: ({ value }) => (value > 0 ? true : 'Budget must be positive'),
}
Both the grid (editable) and the column (editable: true) must opt in.
Opening an editor: double-click the cell, press F2, press Enter, or call
beginEdit(nodeId, columnId).
Committing: Enter, Tab, or clicking away. Cancelling: Escape.
The editor control is inferred from the column's kind — number, date, checkbox, select (when
editorOptions are supplied), or text. Override with editor.
Values are coerced to the column's type before validation, so a validator sees a real number or a real ISO date, never the raw string an input produced.
{
id: 'status', title: 'Status', editable: true,
editorOptions: [
{ value: 'active', label: 'Active' },
{ value: 'paused', label: 'Paused' },
],
}
Who owns the data
The component applies the edit to its own copy immediately and emits tlvCellEditCommit. Persist it,
and revert with updateNode() or setData() if the save fails. This optimistic path keeps editing
responsive over a slow network.
grid.addEventListener('tlvCellEditCommit', async event => {
const { node, column, value, previousValue } = event.detail;
try {
await api.save(node.id, { [column.id]: value });
} catch {
await grid.updateNode(node.id, { [column.id]: previousValue });
}
});
A validator that throws rejects the edit rather than breaking the grid.
Row-level validation (0.5)
A column validator answers "is this a valid price?". A row validator answers "is this price valid for this row?" — the cross-field rules, which need more than the one value.
grid.validateRow = ({ node, column, value }) => {
const { start, end } = node.cells;
return new Date(end) < new Date(start) ? 'End date must be after the start date' : true;
};
It runs after the column's own validator has accepted, so it never has to defend itself against a
value the column already rejected. It receives the row as it would be if the edit committed, so
you read node.cells directly rather than reconstructing the row yourself.
Async validation (0.5)
Either validator may return a Promise — for the checks that need the server:
{
id: 'sku',
title: 'SKU',
editable: true,
validate: async ({ value }) => {
const taken = await api.skuExists(value);
return taken ? 'That SKU is already in use' : true;
},
}
While the promise is pending the editor stays open and disabled, showing validatingText
("Checking..." by default) where the error message would appear. Letting the user carry on and meet
the rejection two rows later is how edits get quietly lost. A rejected promise is treated as a refusal
rather than becoming an unhandled error.
The synchronous path is untouched — a grid with no async validators pays nothing for this.
Clipboard paste
<bm-treelistview editable="true" allow-paste="true"></bm-treelistview>
await grid.pasteFromClipboard(); // Ctrl/Cmd+V does this
await grid.pasteFromText(tsv, { nodeId: 'p1', columnId: 'q1' });
Off by default, deliberately: a paste writes to many cells at once, and a grid that silently accepts a spreadsheet block can lose a lot of data to one stray keystroke.
It parses TSV, not CSV, because that is what Excel, Google Sheets, Numbers and LibreOffice all put
on the clipboard as text/plain. Parsing it as CSV would split Smith, John into two cells. Quoted
fields are still unwrapped, since a cell containing a newline is exported quoted by all of them, and
the trailing blank line spreadsheets append is dropped — without that, a paste would clear the row
below the block the user actually copied.
Where it lands:
- The anchor defaults to the last cell the user edited, then to the active row's first editable column. Pass an explicit anchor to place it precisely.
- Values are coerced to each column's type, so a pasted
1,250becomes the number1250. - Read-only cells inside the block are skipped, not treated as a failure — the rest of the paste
still lands, and
tlvPaste.skippedsays how many were passed over. - A block wider or taller than the space left is trimmed at the edge, never wrapped;
clippedreports it. - Cells whose pasted value equals what is already there are not recorded, so the paste does not leave an undo step that appears to do nothing.
The whole paste is one undo step.
Undo and redo
<bm-treelistview editable="true" allow-undo="true" undo-limit="100"></bm-treelistview>
| Key | Action |
|---|---|
| Ctrl/Cmd+Z | Undo |
| Ctrl/Cmd+Shift+Z | Redo |
| Ctrl+Y | Redo (Windows habit) |
Only data changes are on the stack — committed cell edits and pastes. Sorting, filtering, grouping, selection and column layout are view state and are deliberately excluded. A Ctrl+Z that sometimes changes a number and sometimes re-sorts a column is one users cannot form a model of, so they stop trusting undo entirely.
The history is discarded whenever data arrives from outside the grid —
setData()orsetItemsData(). The application owns the data, so after a refetch or a websocket push the values on the stack may no longer relate to what is on screen, and replaying them would silently overwrite newer data. Losing the history there is the honest outcome; the alternative is an undo that resurrects values the user never saw.
Every undone or redone cell emits tlvCellEditCommit exactly as a fresh edit would, so an application
that persists through that event needs no separate undo listener. tlvHistoryChange fires on
every commit, undo, redo and clear, for driving your own toolbar:
grid.addEventListener('tlvHistoryChange', e => {
undoButton.disabled = !e.detail.canUndo;
redoButton.disabled = !e.detail.canRedo;
});
Cell navigation and range selection
<bm-treelistview cell-navigation="true" allow-cell-range-selection="true"></bm-treelistview>
Off by default. Row navigation is the right model for a grid used as a list or a picker, and switching it under existing applications would be a breaking change in everything but name.
| Key | Action |
|---|---|
| ← → ↑ ↓ | Move one cell |
| Tab / Shift+Tab | Next / previous cell in reading order, wrapping at the row end |
| Home / End | First / last cell of the row |
| Ctrl/Cmd+Home / End | First / last cell of the grid |
| Shift+arrows | Extend the selected rectangle |
| Ctrl/Cmd+C | Copy the rectangle as TSV |
Expansion moves to the tree column. The arrows can't both walk cells and open rows, so the ARIA APG treegrid pattern resolves it by position: on the tree column, → opens a closed row and ← closes an open one; on any other column they simply move. + and - expand and collapse from anywhere, so expansion is never unreachable.
Arrows clamp at the edges; Tab wraps. That asymmetry is deliberate — wrapping on an arrow key makes it impossible to hold a direction down and come to rest at the edge, while a Tab that stops dead at the last column is just broken.
The range
A range is stored as its two corners, not as the cells it covers. Storing the cells would mean rebuilding a potentially enormous set on every arrow key, and — worse — that set would go stale the moment a filter or sort changed which rows lie between the corners. Two corners plus the live row order is always consistent with what is on screen.
const { range, cells } = await grid.getCellRange();
await grid.focusCellAt('p1', 'budget', /* extend */ true);
await grid.copyCellRange();
await grid.clearCellRange();
Ctrl/Cmd+C copies the rectangle in preference to a row selection: the user drew it most recently, and copying whole rows instead would quietly discard the narrowing they just did.
Pinned rows
const items = [
{ id: 'summary', pinned: 'top', cells: { name: 'This quarter', value: 412_000 } },
{ id: 'a', cells: { name: 'Alpha', value: 120_000 } },
{ id: 'new', pinned: 'bottom', cells: { name: 'Add a row…' } },
];
A pinned row renders outside the scrolling body and outside virtualisation. Use it for a running total, a "new row" stub, or the record a user is comparing everything else against.
Pinned rows are lifted out before filtering, sorting and grouping. Pinning says "this row sits outside the list", so the point of it is defeated if typing in the filter box makes it vanish. The filter acts on what remains; the pinned rows stay exactly where you put them, even when the body is empty.
Two consequences worth knowing:
- Only top-level nodes can be pinned. Lifting a nested row out of its subtree would silently
change the shape of the tree around it, so
pinnedon a child is ignored. - They still count. Pinned rows are selectable, are included in the footer totals, and are counted
in
aria-rowcount— they are visible rows that happen not to scroll, not a separate grid.
Style them with --tlv-pinned-row-bg and --tlv-pinned-row-border, or the pinned-rows,
pinned-rows-top and pinned-rows-bottom CSS parts.
Truncation tooltips
On by default. A cell gets a title only when its content is genuinely cut off, measured when the
pointer enters it rather than on every paint.
Two decisions behind that. A truncation test forces a layout read, and doing one per cell per render
would cost more on a large grid than the tooltip is worth. And setting title unconditionally is
worse than not having it: a tooltip repeating text the user can already read is pure noise, and screen
readers announce it on top of the cell's own content. The tooltip is also removed again once a column
is widened enough to show everything.
Turn it off with truncation-tooltips="false".
Conditional formatting
Rules that decide how a cell or a row looks, from what it contains.
grid.conditionalFormats = [
// Cell scope (the default): styles that column's cell only.
{ columnId: 'budget', operator: 'gt', value: 1_000_000, style: { background: '#7f1d1d', color: '#fff' } },
{ columnId: 'budget', operator: 'notEmpty', style: { dataBar: true, barColor: '#0ea5e9' } },
// Row scope: reads one column, styles the whole row.
{ columnId: 'status', scope: 'row', operator: 'equals', value: 'Watch', style: { className: 'at-risk' } },
];
Also settable as a JSON attribute, for markup-only integrations.
Rules are data, not callbacks. A (context) => style function would be more powerful — and is
still available through custom cell renderers — but a rule list survives a
JSON attribute, can be built by a settings screen, stored in a database, and saved with the grid's
state so a user's own formatting comes back next time. A function does none of that.
The first match wins. Rules are evaluated in order and evaluation stops at the first hit. Merging several matches would mean deciding what happens when one rule says red text and another says green, and any answer to that is arbitrary — ordered rules put the decision with whoever wrote the list. A cell rule beats a row rule on the same cell, because the more specific statement should win.
Operators
| Operator | Notes |
|---|---|
equals, notEquals |
exact string comparison |
contains, startsWith, endsWith |
case-insensitive |
gt, gte, lt, lte |
numeric — '1,250' compares as 1250 |
between |
value and value2, in either order |
empty, notEmpty |
null, undefined and '' count as empty |
A blank cell matches nothing except empty. A blank is unknown, not "less than 100".
Data bars
{ columnId: 'revenue', operator: 'notEmpty', style: { dataBar: true, barColor: '#0ea5e9' } }
Bars are scaled across the visible rows of that column, not the whole dataset — filtering to the rows you care about re-scales the bars so the differences between those rows are readable, which is the only reason to draw them. If every visible row holds the same value, every bar is full.
The bar is painted as a background gradient driven by a custom property, so it costs no extra element per cell. On a hundred-thousand-row grid that is the difference between the feature being free and being unusable.
Skeleton loading
<bm-treelistview loading="true" skeleton-rows="8"></bm-treelistview>
Placeholder rows shaped like your real columns, so the layout does not jump when the data lands.
Only shown when there is nothing else to show. On a background refresh —
loadingset while rows are already on screen — the real rows stay. Replacing content the user is reading with grey bars is a step backwards, not a nicety.
They are aria-hidden: a screen reader announcing a dozen empty rows is worse than silence, and the
grid's aria-busy already says what is happening. The shimmer stops under prefers-reduced-motion.
Style with --tlv-skeleton-bar and --tlv-skeleton-shine.
Localisation
Every string the grid announces goes through one catalogue.
grid.messages = {
expandedAll: 'Toutes les lignes développées',
grouped: 'Regroupé par {column}',
rowsSelected: { one: '{count} ligne sélectionnée', other: '{count} lignes sélectionnées' },
};
Supply only the keys you want to change; everything else falls back to English. Accepts an object or a JSON string, so a markup-only integration can translate the grid too — an invalid string is warned about and ignored rather than leaving the grid mute.
Plurals
A counted message may be a string, or an object of CLDR plural categories selected with
Intl.PluralRules against the grid's locale:
// Polish uses one / few / many.
rowsSelected: { one: '{count} wiersz', few: '{count} wiersze', many: '{count} wierszy' }
This is why the catalogue does not just take a function returning a string. English has two plural
forms, so count === 1 ? 'row' : 'rows' looks fine — and is silently wrong in Polish, Russian, Welsh
and Arabic, and needlessly clumsy in Japanese, which has one. Selecting the category properly is the
difference between a grid that has been translated and one that has been word-substituted.
Missing categories fall back to other. A missing key falls back to the key name rather than an empty
string, so a gap shows up in testing instead of producing a silent control.
Printing
printAllRows is on by default.
A virtualised grid holds only the rows near the viewport, so printing one would otherwise produce a report quietly missing most of its data — which is worse than a print that fails outright, because nothing about it looks wrong. Virtualisation is suspended for the duration of the print job and restored afterwards.
The grid listens for beforeprint and afterprint, and — for Safari, which fires neither — the
print media query. Print styles also unclip the scroller and stop the sticky header floating over the
page.
Set print-all-rows="false" if you would rather print only what is on screen.
Export and clipboard
const csv = await grid.exportData(); // CSV, formatted
const raw = await grid.exportData({ format: 'csv', raw: true }); // underlying values
const sel = await grid.exportData({ format: 'json', selectedOnly: true });
await grid.downloadData({ format: 'xlsx' }, 'projects.xlsx'); // real spreadsheet
await grid.copyToClipboardAsText(); // TSV
| Option | Default | |
|---|---|---|
format |
'csv' |
'csv' | 'tsv' | 'json' | 'excel' | 'xlsx' |
selectedOnly |
false |
|
includeHeader |
true |
|
raw |
false |
false exports what the user sees; true exports the underlying value |
columnIds |
all visible | restricts and orders |
includeGroups |
false |
include group header rows |
sheetName |
grid label | xlsx only — cleaned to what Excel accepts |
Formatted or raw? raw: false exports £1,200.00 — right for a report someone will read.
raw: true exports 1200 — right for a file going into another system.
Spreadsheets
There are two spreadsheet formats, and the difference matters.
xlsx is a real Office Open XML workbook. Numbers arrive as numbers, dates as dates, booleans as
booleans, and the header row is frozen — so the recipient can sum a column, filter by month and sort
without touching anything first. It is binary, so it does not come back from exportData():
await grid.downloadData({ format: 'xlsx', selectedOnly: true }, 'q3.xlsx');
// Or take the Blob yourself, to attach or upload it:
const blob = await grid.exportWorkbook({ sheetName: 'Q3 review' });
await fetch('/api/reports', { method: 'POST', body: blob });
Calling exportData({ format: 'xlsx' }) throws rather than returning something plausible — a ZIP
archive forced through a string is corrupt, and an export that is silently wrong is worse than one
that fails.
The writer is dependency-free: no SheetJS, no ExcelJS, nothing added to your bundle.
excel produces an HTML table with an Excel MIME type. Excel and LibreOffice open it directly and
it preserves alignment and bold group headers, but every value in it is text, and tools that read the
file rather than opening it (Numbers, importers, anything server-side) reject it. Prefer xlsx for
new work; excel remains for integrations already built on it.
CSV injection is mitigated. Any field beginning =, +, - or @ is prefixed with a single quote,
so a malicious cell cannot execute when the export is opened in a spreadsheet.
Ctrl/Cmd+C copies the selection as TSV — or the whole view when nothing is selected, matching a
spreadsheet. Set allow-clipboard="false" to disable.
Server-side data source
For datasets too large to hold in the browser, or where the authoritative ordering lives in a database.
grid.dataSource = async ({ startRow, endRow, sortModel, filterText, columnFilters, signal }) => {
const response = await fetch('/api/rows?' + new URLSearchParams({
from: String(startRow),
to: String(endRow),
sort: JSON.stringify(sortModel),
q: filterText,
}), { signal });
const { rows, total } = await response.json();
return { rows, totalRows: total };
};
What the grid stops doing
With a data source attached the grid does not sort, filter, group or paginate the rows it is handed. It renders them in the order they arrive. Everything the request describes is now your job.
This is not a limitation, it is the only coherent answer. Doing both would apply every operation twice: a server returning the top 20 by revenue would have them re-sorted into the local page's own order, and a filter would be applied to an already-filtered set. Being explicit about it is the difference between a data source that works and one that produces subtly wrong pages nobody can explain.
Blocks and debouncing
Requests are aligned to blocks of dataSourceBlockSize (default 100) rather than to the exact visible
range. Scrolling by one row would otherwise fire a request for a range shifted by one, and the server
would spend its life answering almost identical questions. Aligned blocks repeat, and repeat means
cacheable.
Query changes are debounced by dataSourceDebounce (default 250ms), so typing in the filter box does
not fire a request per keystroke.
Stale responses
Every request carries a sequence number and an AbortSignal. A response that is not the answer to
the newest question is discarded rather than rendered.
This is the part that quietly breaks hand-rolled implementations. Typing wid fires three requests;
they can come back in any order, and the one that arrives last may be the answer to wi. Without
sequencing, fast typing intermittently leaves the grid showing results for a query the user has already
moved past — which looks like data corruption and is almost impossible to reproduce on demand.
Honour the signal in your fetch and the browser cancels the connection rather than paying for a
payload nobody will look at.
Other differences
setData(columns, items)andsetItemsData(items)ignore their items argument. The rows belong to the server; obeyingsetData(columns, [])— the natural way to say "columns only" — would erase rows the data source had already delivered. Use them to set columns.refresh()re-asks the server instead of re-running the local pipeline.totalRowsdrives the scrollbar and the page count. Omit it if you genuinely do not know; the grid then shows what it has and keeps asking as the user scrolls, rather than inventing a total it cannot back up.tlvDataSourceErrorfires when your callback rejects. An abort is not an error and is not reported.
Paging
<bm-treelistview page-size="25"></bm-treelistview>
await grid.goToPage(3);
const { page, pageCount, pageSize } = await grid.getPageState();
An alternative to scrolling, not a companion to it — where both paging and virtualisation are set, paging wins, because a page is already a bounded slice and virtualising inside it buys nothing.
The slice comes from the flattened row list, not from the source tree. Slicing the tree instead would put a parent on page 1 and its children on page 2, which is not a page of anything. A group header is a row like any other, so a group can straddle a page boundary — the alternative is pages of wildly different lengths and a page size that means nothing.
Page numbers are clamped, not rejected. A filter can remove most of the rows while the user sits on page 40, and the reasonable response is the last page, not an error and an empty grid.
The built-in pager collapses to first / last / a window around the current page, with ellipses: forty
page buttons is unusable, and previous-and-next alone hides where you are. The current page carries
aria-current="page", so a screen-reader user can tell where they are without relying on colour.
Column virtualisation
<bm-treelistview virtualize-columns="true"></bm-treelistview>
The row virtualiser's counterpart, worth turning on somewhere past thirty or forty columns. A grid with two hundred columns pays for every one of them on every row it paints — a hundred visible rows becomes twenty thousand cells, most scrolled out of sight.
Two things are deliberately exempt:
- Pinned columns and a frozen tree column are always rendered. They are
position: stickyand are meant to stay on screen precisely when their natural offset has scrolled away, so dropping one for being outside the window would make it vanish exactly when it is needed. - Cell navigation still spans every visible column. Arrowing right must be able to reach a column that has not been painted yet; the window follows the focus, not the other way round.
Spacers stand in for the skipped columns so the row keeps its full width. Without them the horizontal scrollbar shrinks to what is painted, and the user cannot scroll to the columns that are missing because they cannot scroll to them.
Virtual scrolling
Renders only the rows near the viewport and reserves the rest with spacer elements, so the scrollbar still reflects the full dataset.
<!-- always on -->
<bm-treelistview virtualize="true" row-height="38"></bm-treelistview>
<!-- on automatically above 200 rows -->
<bm-treelistview virtualize-threshold="200" row-height="38"></bm-treelistview>
rowHeight must match what the rows actually render at, or the spacers will not line up. The
defaults per density are 38 (comfortable), 32 (compact) and 48 (spacious).
overscan (default 8) controls how many rows are rendered beyond each edge. Without it, fast scrolling
shows a band of blank space while the browser catches up.
The threshold exists because virtualisation is a trade: it makes 100,000 rows possible and makes 30 rows
marginally worse (fixed row heights, an extra scroll listener). virtualizeThreshold gets both.
The viewport is measured with a ResizeObserver, so a grid inside a collapsible panel or a resizable
splitter stays correct without the window resizing.
See Performance for large-dataset guidance.
Lazy loading
Mark a node as having children you have not fetched:
{ id: 'dept-4', cells: { name: 'Operations' }, hasChildren: true }
Expanding it emits tlvLazyLoad:
grid.addEventListener('tlvLazyLoad', async event => {
const node = event.detail;
grid.loading = true;
try {
const children = await api.fetchChildren(node.id);
await grid.updateNodeChildren(node.id, children);
} catch (error) {
grid.errorText = 'Could not load children.';
} finally {
grid.loading = false;
}
});
updateNodeChildren() marks the node loaded and expands it. A branch that returns zero children will
not re-request on the next expand.
Drag and drop
<bm-treelistview allow-drag-drop="true"></bm-treelistview>
The component reports the intent; you perform the move, because only you know whether it is legal.
grid.addEventListener('tlvNodeDrop', event => {
const { draggedNode, targetNode, position } = event.detail; // 'before' | 'after' | 'inside'
const next = moveNode(currentItems, draggedNode.id, targetNode.id, position);
grid.setItemsData(next, true); // true = keep the user's expansion state
});
Where a drop lands is decided by the pointer's position in the row: the top quarter means before, the bottom quarter after, and the middle half inside (as a child) when the target can hold children. A live indicator shows the answer before the user lets go.
Changed in 0.2. In 0.1 the position came from modifier keys — Alt for "inside", Shift for "before" — which is undiscoverable and impossible on a touch screen. The
positionvalues in the event payload are unchanged, so listeners keep working.
Dropping a node into its own subtree is refused, since the result would be a cycle.
Set node.draggable = false to pin an individual row.
State persistence
A snapshot captures what the user changed — selection, expansion, column widths and order, sort, filters, grouping — and nothing the application configured, so restoring one into a differently configured grid is safe.
const snapshot = await grid.getState();
localStorage.setItem('my-grid', JSON.stringify(snapshot));
await grid.restoreState(JSON.parse(localStorage.getItem('my-grid')));
Or let the component do it:
<bm-treelistview persist-state="true" state-storage-key="projects-grid"></bm-treelistview>
interface TlvStateSnapshot {
selectedIds: string[];
expandedIds: string[];
columnWidths: Record<string, string>;
hiddenColumnIds: string[];
sortColumnId?: string;
sortDirection: 'asc' | 'desc' | 'none';
filterText: string;
columnOrder?: string[]; // 0.2
sortModel?: TlvSortDescriptor[]; // 0.2
columnFilters?: TlvColumnFilter[]; // 0.2
groupBy?: TlvGroupDescriptor[]; // 0.2
collapsedGroupIds?: string[]; // 0.2
version?: number; // 0.2
}
A 0.1 snapshot sitting in a user's localStorage restores cleanly into 0.2: sortColumnId /
sortDirection are read when sortModel is absent, and are always written alongside it.
tlvStateChange fires on every change whether or not persistState is on, so you can persist server-side
instead. Storage failures (private browsing, full quota) are swallowed — the grid keeps working and the
event still fires.
Saved views (0.9)
A view is a named arrangement: which columns, in what order and how wide, sorted and filtered and grouped how. Users build one layout for the Monday review and another for chasing overdue accounts, and want to move between them without rebuilding either.
await grid.saveView('Overdue accounts');
await grid.applyView('Overdue accounts');
const views = await grid.getViews(); // [{ id, name, updatedAt, state }]
await grid.deleteView('Overdue accounts'); // by name or by id
Persist them by giving the grid a key:
<bm-treelistview views-storage-key="projects-views"></bm-treelistview>
Or keep them on your server, which is what a shared view needs:
grid.addEventListener('tlvViewsChange', event => save(event.detail.views));
await grid.setViews(await load());
A view does not contain the selection or the expanded rows. Both describe records, and records come and go: an id expanded last Tuesday may not exist today, and restoring a selection the user cannot see is worse than restoring nothing. Applying a view therefore rearranges the grid the user is looking at — it does not empty it.
Saving over a name already in use replaces it. That is what "save" means to a user; the alternative is a menu that slowly fills with Report (2), Report (3).
| Member | |
|---|---|
saveView(name) |
returns the saved TlvSavedView |
applyView(idOrName) |
false if there is no such view |
deleteView(idOrName) |
false if there was nothing to delete |
getViews() / setViews(views) |
the whole list |
views-storage-key |
omit to keep views in memory only |
tlvViewsChange |
{ views }, on every change |
Master / detail (0.9)
A detail panel is a full-width row that opens beneath its own row: an invoice's line items, a customer's recent activity, a chart. It is a separate disclosure from expanding children — a row can have both, and collapsing the subtree does not close the panel the user opened to read.
grid.detailRenderer = ({ node }) => `
<dl>
<dt>Owner</dt><dd>${node.cells.owner}</dd>
<dt>Region</dt><dd>${node.cells.region}</dd>
</dl>`;
await grid.toggleDetail('p1'); // open or close
await grid.toggleDetail('p1', true); // open, idempotently
await grid.getOpenDetailIds(); // ['p1']
The renderer may return an HTML string or an element. To mount a framework component instead, leave it unset and listen for the attach event — this is the hook the React, Angular and Vue wrappers use:
grid.addEventListener('tlvDetailAttach', ({ detail }) => {
createRoot(detail.container).render(<InvoiceLines id={detail.node.id} />);
});
tlvDetailAttach fires once per panel opening, not on every render, so an unrelated cell change does
not remount your component. tlvDetailToggle fires with { node, open } whenever a panel opens or closes.
detail-height is fixed, and that is deliberate. Virtual scrolling positions rows arithmetically; it
cannot reserve space for a height it can only discover after layout. Content taller than the panel scrolls
inside it. The virtualiser accounts for every open panel when it computes the window, so the scrollbar
stays honest and rows stay where the pointer expects them.
| Member | Default | |
|---|---|---|
detailRenderer |
— | ({ node, index }) => string | HTMLElement |
detailHeight |
160 |
pixels |
toggleDetail(nodeId, open?) |
returns the resulting open state | |
getOpenDetailIds() |
||
tlvDetailToggle |
{ node, open } |
|
tlvDetailAttach |
{ node, container } |
Style it with the detail-row and detail-panel CSS parts, or the --tlv-detail-* custom properties
listed in theming.md.
Custom cell renderers
cellRenderers maps a column id to a function. Because it is a property, not an attribute, it can only
be set from JavaScript.
grid.cellRenderers = {
status: ({ value, node }) => {
const el = document.createElement('span');
el.textContent = String(value);
el.className = value === 'blocked' ? 'my-blocked' : 'my-ok';
return el;
},
};
The context object:
interface TlvCellRendererContext {
node: TlvNode;
column: TlvColumn;
value: unknown;
level: number;
selected: boolean;
expanded: boolean;
rowIndex?: number;
matches?: { start: number; end: number }[];
}
A renderer wins over the built-in for that column. For most styling needs, a kind plus a
::part() rule is simpler and survives upgrades better.
Slots
| Slot | Requires | |
|---|---|---|
toolbar-left |
show-toolbar |
|
toolbar-right |
show-toolbar |
|
footer |
show-footer |
(0.2) |
empty |
— | replaces the no-records message |
loading |
— | replaces the spinner |
error |
— | replaces the error text |
<bm-treelistview show-toolbar="true" show-footer="true">
<div slot="toolbar-left"><h2>Projects</h2></div>
<div slot="toolbar-right"><button id="add">Add</button></div>
<span slot="footer">Last synced 09:42</span>
<div slot="empty">Nothing matches those filters.</div>
</bm-treelistview>
CSS parts
The component uses Shadow DOM, so host page CSS cannot reach inside it. ::part() is the sanctioned way through.
bm-treelistview::part(header-cell) { text-transform: uppercase; letter-spacing: 0.04em; }
bm-treelistview::part(row-selected) { outline: 2px solid #2563eb; }
bm-treelistview::part(badge)[data-value='Blocked'] { background: #fee2e2; color: #991b1b; }
| Part | |
|---|---|
shell, toolbar, toolbar-left, toolbar-right |
|
filter-bar, filter-input |
|
group-panel, group-chip |
(0.2) |
header, header-cell, header-title, sort-indicator, resize-handle |
|
header-bands, band-cell, band-title |
banded header (0.3) |
pinned-rows, pinned-rows-top, pinned-rows-bottom |
pinned rows (0.6) |
pager, pager-button |
pagination controls (0.8) |
detail-row, detail-panel |
master/detail (0.9) |
filter-row, filter-cell, filter-operator, filter-value |
(0.2) |
scroll, table, body |
|
row, row-selected, cell, cell-<columnId>, cell-content |
cell-<columnId> is (0.2) |
group-row, group-cell, group-label, group-aggregates |
(0.2) |
totals-row, totals-cell |
(0.2) |
expander, expander-icon, node-icon, checkbox, select-all-checkbox |
|
badge, chip, chips, progress, progress-track, progress-value, progress-text |
|
link, boolean, rating, sparkline, avatar, avatar-image, avatar-initials |
new kinds (0.2) |
match |
filter highlight (0.2) |
actions-content, action-button, action-primary, action-danger |
|
editor, editor-wrap, editor-error |
(0.2) |
column-menu, column-menu-item |
(0.2) |
footer, footer-item, footer-mode |
|
empty, loading, error, demo-badge, demo-limit-notice |
For colours and spacing, prefer the --tlv-* custom properties — see Theming.
TypeScript types
import type {
TlvColumn,
TlvNode,
TlvRowAction,
TlvCellKind,
TlvCellRenderer,
TlvSelectionMode,
TlvSortDescriptor,
TlvColumnFilter,
TlvGroupDescriptor,
TlvAggregate,
TlvExportOptions,
TlvStateSnapshot,
TlvTheme,
TlvDensity,
TlvMode,
TlvSavedView, // 0.9
TlvViewState, // 0.9
} from 'bm-treelistview-webcomponent';
Types are also re-exported from the component module, so 0.1 imports keep resolving:
import type { TlvColumn } from 'bm-treelistview-webcomponent/dist/types/components/tlv-treelistview/bm-treelistview';
Typing the element
const grid = document.getElementById('grid') as HTMLBmTreelistviewElement;
await grid.ready();
await grid.setData(columns, items);
HTMLBmTreelistviewElement is generated into dist/types/components.d.ts.
See also
- Getting started
- Integration guide — React, Angular, Vue, Svelte, Blazor
- Recipes
- Theming
- Accessibility
- Performance
- What's new in 0.2
- Demo and commercial builds