Integration Guide
How to use bm-treelistview inside each major framework.
The two rules that cover every framework
A Web Component has two input channels, and the difference explains almost every integration problem anyone hits:
- Attributes are strings. Only strings.
columns="[object Object]"is what you get if a framework tries to put an array into one. - Properties carry real values — arrays, objects, functions.
So:
Complex values (
columns,items,rowActions,cellRenderers) must be set as properties, not attributes. Booleans, strings and numbers are fine either way.
And events are native CustomEvents named in camelCase (tlvSelectionChange), so frameworks that
lower-case event names need a small nudge.
Wrappers, or the bare element? (0.10)
Both work, and they are the same component either way.
Use a wrapper if you are writing React, Angular or Vue and want typed props, typed events and
nothing to remember. wrappers/ in your package holds one per framework, generated from the
component's own API metadata — so a property the component has is a property the wrapper has, in every
release.
Use the bare element for anything else, for Svelte and Blazor, or when you would rather own the twenty lines yourself. The rest of this guide covers that route, and it is what the wrappers do internally.
A wrapper contains no component code. It renders the tag and bridges properties and events; the runtime is loaded separately, exactly as below. That is why one wrapper works with the demo edition and the commercial one alike.
| React | Angular | Vue 3 | |
|---|---|---|---|
| Ships as | package (wrappers/react) |
source file to copy | package (wrappers/vue) |
| Import | BmTreeListView |
BmTreeListViewComponent |
BmTreeListView |
| Events | onTlvSelectionChange={fn} |
(tlvSelectionChange)="fn($event)" |
@tlvSelectionChange="fn" |
| The element | ref.current |
grid.element |
grid.value.element |
Each wrapper folder has its own README with a worked example.
Plain HTML and JavaScript
Nothing special required.
<div style="height: 520px">
<bm-treelistview id="grid" selection-mode="checkbox" show-filter="true"></bm-treelistview>
</div>
<script src="./bm-treelistview.min.js"></script>
<script>
customElements.whenDefined('bm-treelistview').then(async () => {
const grid = document.getElementById('grid');
await grid.ready();
await grid.setData(columns, items);
grid.addEventListener('tlvSelectionChange', event => console.log(event.detail));
});
</script>
React
With the wrapper (0.10)
import { BmTreeListView, type BmTreeListViewElement } from 'bm-treelistview-webcomponent-react';
// Hoisted: a columns array rebuilt inline is a new array every render.
const columns = [
{ id: 'name', title: 'Name', tree: true },
{ id: 'owner', title: 'Owner' },
];
export function Projects({ items }) {
const grid = useRef<BmTreeListViewElement>(null);
return (
<BmTreeListView
ref={grid}
columns={columns}
items={items}
selectionMode="checkbox"
showFilter
style={{ height: 520 }}
onTlvSelectionChange={event => setSelected(event.detail)}
onTlvLazyLoad={async event => {
const children = await api.children(event.detail.id);
await grid.current?.updateNodeChildren(event.detail.id, children);
}}
/>
);
}
Props are camelCase and typed; every event is an onTlv… prop carrying the real CustomEvent. The ref
is the element, so the whole imperative API is on it. Works on React 18 and 19.
Without the wrapper
React 19+ passes unknown props to custom elements as properties when the value is not a string, so much of this is automatic. A ref is still the most predictable route, and it is the only one that works identically on React 18.
import { useEffect, useRef } from 'react';
import type { TlvColumn, TlvNode } from './treelistview-types';
interface Props {
columns: TlvColumn[];
items: TlvNode[];
onSelect?: (nodes: TlvNode | TlvNode[]) => void;
}
export function TreeListView({ columns, items, onSelect }: Props) {
const ref = useRef<HTMLElement & Record<string, any>>(null);
// Data: set as properties, never as attributes.
useEffect(() => {
const element = ref.current;
if (!element) return;
let cancelled = false;
(async () => {
await element.ready();
if (!cancelled) {
await element.setData(columns, items);
}
})();
return () => { cancelled = true; };
}, [columns, items]);
// Events: native listeners, because React does not know tlvSelectionChange.
useEffect(() => {
const element = ref.current;
if (!element || !onSelect) return;
const handler = (event: Event) => onSelect((event as CustomEvent).detail);
element.addEventListener('tlvSelectionChange', handler);
return () => element.removeEventListener('tlvSelectionChange', handler);
}, [onSelect]);
return (
<div style={{ height: 520 }}>
{/* Simple values are safe as attributes. */}
<bm-treelistview ref={ref} selection-mode="checkbox" show-filter="true" theme="auto" />
</div>
);
}
Tell TypeScript the tag exists:
// treelistview.d.ts
declare namespace JSX {
interface IntrinsicElements {
'bm-treelistview': React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & Record<string, unknown>,
HTMLElement
>;
}
}
Keep columns and items stable
useEffect compares by reference. A columns array rebuilt inline on every render re-runs setData()
every render. Hoist it to a module constant, or wrap it in useMemo.
Angular
With the wrapper (0.10)
Copy wrappers/angular/src/bm-tree-list-view.component.ts into your application — it ships as source so
your Angular compiles it, which is what keeps it working across Angular majors that would strand a
prebuilt library.
import { BmTreeListViewComponent } from './bm-tree-list-view.component';
@Component({
standalone: true,
imports: [BmTreeListViewComponent],
template: `
<bm-tree-list-view
[columns]="columns"
[items]="items"
selectionMode="checkbox"
[showFilter]="true"
style="height: 520px"
(tlvSelectionChange)="onSelect($event)">
</bm-tree-list-view>`,
})
export class ProjectsComponent {
@ViewChild(BmTreeListViewComponent) grid!: BmTreeListViewComponent;
columns: TlvColumn[] = [ /* ... */ ];
items: TlvNode[] = [ /* ... */ ];
onSelect(event: CustomEvent) {
this.selected = event.detail;
}
exportXlsx() {
return this.grid.element.downloadData({ format: 'xlsx' }, 'projects.xlsx');
}
}
Inputs are real @Inputs, so [itmes]="rows" fails the build instead of quietly doing nothing.
Listeners are bound outside Angular's zone and re-enter it only when something is subscribed, so a grid
firing scroll-driven events at 60Hz does not cause 60 change-detection passes a second.
Without the wrapper
Add CUSTOM_ELEMENTS_SCHEMA so Angular stops complaining about the unknown tag.
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@NgModule({
declarations: [ProjectsComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class ProjectsModule {}
Angular's [prop] binding sets a property, which is exactly what is needed:
<div style="height: 520px">
<bm-treelistview
#grid
[columns]="columns"
[items]="items"
selection-mode="checkbox"
show-filter="true"
theme="auto"
(tlvSelectionChange)="onSelect($event)"
(tlvLazyLoad)="onLazyLoad($event)">
</bm-treelistview>
</div>
export class ProjectsComponent {
@ViewChild('grid') grid!: ElementRef<HTMLElement & Record<string, any>>;
columns: TlvColumn[] = [ /* ... */ ];
items: TlvNode[] = [ /* ... */ ];
onSelect(event: CustomEvent) {
this.selected = event.detail;
}
async onLazyLoad(event: CustomEvent) {
const children = await this.api.children(event.detail.id);
await this.grid.nativeElement.updateNodeChildren(event.detail.id, children);
}
async exportCsv() {
await this.grid.nativeElement.downloadData({ format: 'csv' }, 'projects.csv');
}
}
Angular's
(eventName)binding is case-sensitive and maps straight toaddEventListener, so(tlvSelectionChange)works as written.
Standalone components
@Component({
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: `<bm-treelistview [columns]="columns" [items]="items"></bm-treelistview>`,
})
export class ProjectsComponent {}
Vue 3
With the wrapper (0.10)
<script setup lang="ts">
import { BmTreeListView } from 'bm-treelistview-webcomponent-vue';
const grid = ref();
const columns = [ /* ... */ ];
const items = ref([]);
async function exportXlsx() {
await grid.value.element.downloadData({ format: 'xlsx' }, 'projects.xlsx');
}
</script>
<template>
<BmTreeListView
ref="grid"
:columns="columns"
:items="items"
selection-mode="checkbox"
style="height: 520px"
@tlvSelectionChange="event => (selected = event.detail)"
/>
</template>
No isCustomElement configuration is needed on this route — the wrapper is a Vue component, and it is
the one rendering the tag.
Without the wrapper
Tell Vue the tag is a custom element so it does not try to resolve a component:
// vite.config.js
export default {
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: tag => tag.startsWith('bm-'),
},
},
}),
],
};
Vue sets a property when one exists on the element and falls back to an attribute otherwise, so :columns
does the right thing:
<template>
<div style="height: 520px">
<bm-treelistview
ref="grid"
:columns="columns"
:items="items"
selection-mode="checkbox"
show-filter="true"
theme="auto"
@tlvSelectionChange="onSelect"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const grid = ref(null);
const columns = [ /* ... */ ];
const items = ref([ /* ... */ ]);
function onSelect(event) {
selected.value = event.detail;
}
onMounted(async () => {
await grid.value.ready();
});
</script>
If an event does not arrive, bind it explicitly with @[tlvSelectionChange] or add a native listener in
onMounted — some Vue versions lower-case DOM event names in templates.
Svelte
Svelte sets a property when the element has one, so this is close to plain HTML:
<script>
import { onMount } from 'svelte';
let grid;
export let columns = [];
export let items = [];
onMount(async () => {
await grid.ready();
await grid.setData(columns, items);
});
</script>
<div style="height: 520px">
<bm-treelistview
bind:this={grid}
selection-mode="checkbox"
show-filter="true"
on:tlvSelectionChange={event => dispatch('select', event.detail)}
/>
</div>
Blazor
@inject IJSRuntime JS
<div style="height: 520px">
<bm-treelistview @ref="gridElement" id="projects-grid"
selection-mode="checkbox" show-filter="true"></bm-treelistview>
</div>
@code {
private ElementReference gridElement;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
await JS.InvokeVoidAsync("treeListViewInterop.setData", gridElement, Columns, Items);
}
}
// wwwroot/js/treelistview-interop.js
window.treeListViewInterop = {
async setData(element, columns, items) {
await element.ready();
await element.setData(columns, items);
},
onSelectionChange(element, dotNetRef) {
element.addEventListener('tlvSelectionChange', event => {
dotNetRef.invokeMethodAsync('OnSelectionChanged', event.detail);
});
},
};
Bundlers and module systems
The runtime is a self-registering IIFE. Load it once, anywhere, before the first grid renders.
// A side-effect import works in every bundler.
import './vendor/bm-treelistview.min.js';
For Vite, Webpack or Rollup, keeping the file in public/ (or wwwroot/) and loading it with a script
tag is simplest — there is nothing to tree-shake and nothing to transpile.
Server-side rendering
The component needs a DOM. Under Next.js, Nuxt or Angular Universal, render it client-side only:
const TreeListView = dynamic(() => import('./TreeListView'), { ssr: false });
The markup is inert without the runtime, so a server-rendered <bm-treelistview> tag is harmless — it
simply stays empty until hydration.
Content Security Policy
The component ships no inline scripts and loads nothing at runtime. It does inject its stylesheet into a shadow root, which needs:
style-src 'self' 'unsafe-inline';
If your CSP forbids inline styles entirely, use a nonce-based or hash-based policy for the constructed stylesheet, or serve the component from a context that allows it.
Common problems
| Symptom | Cause | Fix |
|---|---|---|
| Grid is a thin sliver | No height on the element or its parent | Give the parent a height |
| Empty grid, no errors | columns/items set as attributes |
Set them as properties, or use setData() |
setData is not a function |
Called before the element upgraded | await customElements.whenDefined('bm-treelistview') then await grid.ready() |
componentOnReady is not a function |
That method only exists in Stencil's lazy build, not the single-file runtime | Use await grid.ready() |
| Events never fire | Framework lower-cased the event name | Use addEventListener directly |
| Numbers sort alphabetically | Column has no numeric kind |
Add kind: 'currency' | 'number', or valueType: 'number' |
| Virtualised rows overlap or gap | rowHeight ≠ actual row height |
Match row-height to the density |
| Styles do not apply | Shadow DOM boundary | Use --tlv-* variables or ::part() |