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:

  1. Attributes are strings. Only strings. columns="[object Object]" is what you get if a framework tries to put an array into one.
  2. 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 to addEventListener, 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()