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

  1. Commercial distribution model
  2. Quick start
  3. Supplying data
  4. Column model
  5. Cell kinds
  6. Node model
  7. Row actions
  8. Properties
  9. Methods
  10. Events
  11. Selection
  12. Column sizing and keyboard control
  13. Sorting
  14. Filtering
  15. Grouping and aggregation
  16. Inline editing
  17. Clipboard paste
  18. Undo and redo
  19. Cell navigation and range selection
  20. Pinned rows
  21. Truncation tooltips
  22. Conditional formatting
  23. Skeleton loading
  24. Localisation
  25. Printing
  26. Export and clipboard
  27. Server-side data source
  28. Paging
  29. Column virtualisation
  30. Virtual scrolling
  31. Lazy loading
  32. Drag and drop
  33. State persistence
  34. Custom cell renderers
  35. Slots
  36. CSS parts
  37. TypeScript types
  38. 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: block with height: 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.

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: selectionModeselection-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 lazy dist build; 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:

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


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 kindnumber, 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 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 gridsetData() or setItemsData(). 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:

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 — loading set 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


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:

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 position values 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