Recipes

Worked solutions to things people actually need to build.


Contents


A file browser

Icons, sizes, dates, and a leaf glyph so files and folders read differently.

await grid.setData(
  [
    { id: 'name',     title: 'Name', tree: true, width: '340px' },
    { id: 'size',     title: 'Size', kind: 'number', align: 'right', width: '110px', emptyText: '—' },
    { id: 'modified', title: 'Modified', kind: 'date', width: '150px' },
    { id: 'kind',     title: 'Kind', kind: 'badge', width: '120px' },
  ],
  [
    {
      id: '/src',
      icon: '📁',
      cells: { name: 'src', modified: '2026-02-11', kind: 'Folder' },
      children: [
        { id: '/src/index.ts', icon: '📄',
          cells: { name: 'index.ts', size: 4210, modified: '2026-02-14', kind: 'TypeScript' } },
      ],
    },
  ],
);

grid.leafIcon = '·';   // keeps leaf rows aligned with folder rows
grid.addEventListener('tlvRowDoubleClick', event => {
  const node = event.detail;
  if (!node.children?.length && !node.hasChildren) openFile(node.id);
});

A project portfolio with budgets and totals

<bm-treelistview
  id="grid"
  mode="list"
  show-footer="true"
  show-totals="true"
  show-column-filters="true"
  allow-grouping="true"
  show-group-panel="true"
  selection-mode="checkbox"
  theme="auto">
</bm-treelistview>
await grid.setData(
  [
    { id: 'name',     title: 'Project',  tree: true, width: '260px' },
    { id: 'owner',    title: 'Owner',    kind: 'avatar', width: '200px' },
    { id: 'region',   title: 'Region',   kind: 'badge', width: '110px' },
    { id: 'budget',   title: 'Budget',   kind: 'currency', align: 'right',
      width: '140px', aggregate: 'sum' },
    { id: 'spent',    title: 'Spent',    kind: 'currency', align: 'right',
      width: '140px', aggregate: 'sum' },
    { id: 'progress', title: 'Progress', kind: 'progress', width: '160px', aggregate: 'avg' },
    { id: 'due',      title: 'Due',      kind: 'date', width: '130px' },
  ],
  projects,
);

await grid.setGroupBy([{ columnId: 'region' }]);

Each region header now shows its own budget total and average progress, and a sticky totals row at the bottom shows the same across everything visible.


Server-side paging with lazy loading

Fetch children only when a branch is opened.

const columns = [
  { id: 'name',  title: 'Department', tree: true, width: '300px' },
  { id: 'head',  title: 'Head',  width: '180px' },
  { id: 'staff', title: 'Staff', kind: 'number', align: 'right', width: '100px' },
];

// Roots only. hasChildren tells the grid to show an expander.
const roots = (await api.departments({ parentId: null })).map(row => ({
  id: row.id,
  cells: { name: row.name, head: row.head, staff: row.staff },
  hasChildren: row.childCount > 0,
}));

await grid.setData(columns, roots);

grid.addEventListener('tlvLazyLoad', async event => {
  const node = event.detail;

  grid.loading = true;
  try {
    const rows = await api.departments({ parentId: node.id });

    await grid.updateNodeChildren(
      node.id,
      rows.map(row => ({
        id: row.id,
        cells: { name: row.name, head: row.head, staff: row.staff },
        hasChildren: row.childCount > 0,
      })),
    );
  } catch (error) {
    grid.errorText = `Could not load ${node.cells.name}.`;
  } finally {
    grid.loading = false;
  }
});

A branch that returns zero children is marked loaded and will not re-request.


An editable grid that saves to an API

await grid.setData(
  [
    { id: 'name', title: 'Task', tree: true, width: '300px', editable: true },
    {
      id: 'status', title: 'Status', kind: 'badge', width: '150px', editable: true,
      editorOptions: [
        { value: 'todo',    label: 'To do' },
        { value: 'active',  label: 'In progress' },
        { value: 'done',    label: 'Done' },
      ],
    },
    {
      id: 'estimate', title: 'Estimate', kind: 'number', align: 'right',
      width: '120px', editable: true,
      validate: ({ value }) => {
        if (value === null) return 'Enter a number of hours.';
        if (value < 0) return 'Estimate cannot be negative.';
        if (value > 200) return 'Estimates over 200 hours need approval.';
        return true;
      },
    },
    { id: 'due', title: 'Due', kind: 'date', width: '140px', editable: true },
  ],
  tasks,
);

grid.editable = true;

grid.addEventListener('tlvCellEditCommit', async event => {
  const { node, column, value, previousValue } = event.detail;

  try {
    await api.patchTask(node.id, { [column.id]: value });
  } catch {
    // The grid already applied the change optimistically - roll it back.
    await grid.updateNode(node.id, { [column.id]: previousValue });
    showToast('Could not save that change.');
  }
});

Users open an editor by double-clicking, or by pressing F2 or Enter. Tab commits and moves on.


A right-click context menu

The component suppresses the browser menu and hands you the coordinates.

grid.addEventListener('tlvContextMenu', event => {
  const { node, x, y, column } = event.detail;

  menu.style.left = `${x}px`;
  menu.style.top = `${y}px`;
  menu.dataset.nodeId = node.id;
  menu.dataset.columnId = column?.id ?? '';
  menu.hidden = false;
});

document.addEventListener('click', () => (menu.hidden = true));

column tells you which cell was clicked, so you can offer column-specific commands such as "Filter by this value".


Reordering rows by dragging

The grid reports the intent; you perform the move.

<bm-treelistview allow-drag-drop="true"></bm-treelistview>
let items = initialItems;

grid.addEventListener('tlvNodeDrop', async event => {
  const { draggedNode, targetNode, position } = event.detail;

  items = moveNode(items, draggedNode.id, targetNode.id, position);

  // preserveExpansion keeps the user's open branches open across the move.
  await grid.setItemsData(items, true);

  await api.reorder(draggedNode.id, targetNode.id, position);
});

/** Remove a node from wherever it is and reinsert it relative to a target. */
function moveNode(tree, draggedId, targetId, position) {
  let dragged;

  const remove = nodes =>
    nodes.filter(node => {
      if (node.id === draggedId) { dragged = node; return false; }
      if (node.children) node.children = remove(node.children);
      return true;
    });

  const insert = nodes =>
    nodes.flatMap(node => {
      if (node.id === targetId) {
        if (position === 'inside') {
          return [{ ...node, children: [...(node.children ?? []), dragged] }];
        }
        return position === 'before' ? [dragged, node] : [node, dragged];
      }

      return [{ ...node, children: node.children ? insert(node.children) : node.children }];
    });

  return insert(remove(structuredClone(tree)));
}

The drop position comes from where the pointer is in the row — top quarter before, bottom quarter after, middle inside — and a live indicator shows it before the user commits.


Remembering the user's view

The built-in way:

<bm-treelistview persist-state="true" state-storage-key="projects-grid"></bm-treelistview>

Server-side instead, so the view follows the user between devices:

grid.addEventListener('tlvStateChange', debounce(event => {
  api.saveGridState('projects', event.detail);
}, 800));

const saved = await api.loadGridState('projects');
if (saved) await grid.restoreState(saved);

A snapshot holds only what the user changed, so restoring one into a grid whose columns have since changed is safe: unknown ids are skipped and new columns appear.


Master/detail layout

<div class="split">
  <bm-treelistview id="grid" selection-mode="single" selection-follows-focus="true"></bm-treelistview>
  <section id="detail"></section>
</div>
grid.addEventListener('tlvSelectionChange', event => {
  const node = event.detail;          // single mode gives one node
  renderDetail(node && node.id ? node : null);
});

selection-follows-focus (the default) means arrow keys move the detail pane too, which is what a master/detail layout wants. Turn it off for multi-select grids.


An export toolbar

<bm-treelistview id="grid" show-toolbar="true" selection-mode="checkbox">
  <div slot="toolbar-right">
    <button data-format="csv">CSV</button>
    <button data-format="excel">Excel</button>
    <button data-format="json">JSON</button>
    <button id="copy">Copy selection</button>
  </div>
</bm-treelistview>
document.querySelectorAll('[data-format]').forEach(button => {
  button.addEventListener('click', () =>
    grid.downloadData(
      { format: button.dataset.format, selectedOnly: hasSelection() },
      `projects.${button.dataset.format === 'excel' ? 'xls' : button.dataset.format}`,
    ),
  );
});

document.getElementById('copy').addEventListener('click', async () => {
  const copied = await grid.copyToClipboardAsText(true);
  showToast(copied ? 'Copied' : 'Clipboard unavailable');
});

Add raw: true when the file is going into another system rather than to a person.


Colour-coded status badges

No JavaScript needed — the badge writes its value to data-value.

{ id: 'status', title: 'Status', kind: 'badge', width: '140px' }
bm-treelistview::part(badge)[data-value='Blocked'] {
  background: #fee2e2;
  color: #991b1b;
}

bm-treelistview::part(badge)[data-value='Shipped'] {
  background: #dcfce7;
  color: #166534;
}

For anything more elaborate, use a custom renderer:

grid.cellRenderers = {
  status: ({ value }) => {
    const span = document.createElement('span');
    span.className = `status status-${String(value).toLowerCase()}`;
    span.textContent = String(value);
    return span;
  },
};

Grouping with a summary line

await grid.setGroupBy([{ columnId: 'region' }]);

Each group header shows its count and every aggregate its columns declare. To restyle:

bm-treelistview::part(group-row) {
  background: #0f172a;
  color: #e2e8f0;
}

bm-treelistview::part(group-aggregates) {
  font-variant-numeric: tabular-nums;
}

Collapse everything at once:

await grid.collapseAllGroups();

Filtering from outside the grid

<input id="search" type="search" placeholder="Search projects">
<select id="region"><option value="">All regions</option>…</select>
search.addEventListener('input', debounce(() => grid.setFilterText(search.value), 150));

region.addEventListener('change', () => {
  grid.setColumnFilters(
    region.value ? [{ columnId: 'region', operator: 'equals', value: region.value }] : [],
  );
});

The two layers combine with AND, so a search term and a region filter narrow together.

Debouncing the text input is worth it above a few thousand rows: each keystroke re-runs the filter.


A 100,000-row grid

<bm-treelistview
  id="grid"
  mode="list"
  virtualize="true"
  row-height="32"
  density="compact"
  overscan="10"
  show-footer="true">
</bm-treelistview>
await grid.setData(columns, hugeDataset);

// Jump straight to a row, wherever it is.
await grid.scrollToNode('row-84210', 'center');

Match row-height to the density (32 for compact, 38 comfortable, 48 spacious) or the spacers will not line up. See Performance.


Reacting to a theme toggle

const media = matchMedia('(prefers-color-scheme: dark)');

// Or simply: grid.theme = 'auto';
function apply() {
  grid.theme = media.matches ? 'dark' : 'light';
}

media.addEventListener('change', apply);
apply();

theme="auto" does this for you. Set the theme explicitly only when your application has its own light/dark switch that should override the operating system.