Skip to content

DataTable — usage examples

DataTable ships as six pre-compiled, per-framework packages from a single .rozie source — install only the one for your framework (no Rozie toolchain, no build-time compile step). Each carries its engine + framework peers as peer dependencies, so you control their versions. The snippets below are the same idiomatic consumption code shown in each package's README; switch the tab to your framework.

Usage

Columns as a config array

tsx
import { useState } from 'react';
import { DataTable } from '@rozie-ui/data-table-react';

export function Demo() {
  const rows = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];
  const columns = [
    { field: 'name',   header: 'Name',   sortable: true, filterable: true },
    { field: 'email',  header: 'Email' },
    { field: 'status', header: 'Status', sortable: true },
  ];
  const [sorting, setSorting] = useState<{ id: string; desc: boolean }[]>([]);
  return (
    <DataTable
      data={rows}
      columns={columns}
      sorting={sorting}
      onSortChange={setSorting}
      selectionMode="multiple"
      stickyHeader
    />
  );
}
vue
<script setup lang="ts">
import { ref } from 'vue';
import DataTable from '@rozie-ui/data-table-vue';

const rows = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];
const columns = [
    { field: 'name',   header: 'Name',   sortable: true, filterable: true },
    { field: 'email',  header: 'Email' },
    { field: 'status', header: 'Status', sortable: true },
  ];
const sorting = ref<{ id: string; desc: boolean }[]>([]);
</script>

<template>
  <DataTable :data="rows" :columns="columns" v-model:sorting="sorting" selection-mode="multiple" sticky-header />
</template>
svelte
<script lang="ts">
  import DataTable from '@rozie-ui/data-table-svelte';

  const rows = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];
  const columns = [
    { field: 'name',   header: 'Name',   sortable: true, filterable: true },
    { field: 'email',  header: 'Email' },
    { field: 'status', header: 'Status', sortable: true },
  ];
  let sorting = $state<{ id: string; desc: boolean }[]>([]);
</script>

<DataTable data={rows} {columns} bind:sorting selectionMode="multiple" stickyHeader />
ts
import { Component } from '@angular/core';
import { DataTable } from '@rozie-ui/data-table-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [DataTable],
  template: `
    <DataTable [data]="rows" [columns]="columns" [(sorting)]="sorting" selectionMode="multiple" [stickyHeader]="true" />
  `,
})
export class DemoComponent {
  rows = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];
  columns = [
    { field: 'name',   header: 'Name',   sortable: true, filterable: true },
    { field: 'email',  header: 'Email' },
    { field: 'status', header: 'Status', sortable: true },
  ];
  sorting: { id: string; desc: boolean }[] = [];
}
tsx
import { createSignal } from 'solid-js';
import { DataTable } from '@rozie-ui/data-table-solid';

export function Demo() {
  const rows = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];
  const columns = [
    { field: 'name',   header: 'Name',   sortable: true, filterable: true },
    { field: 'email',  header: 'Email' },
    { field: 'status', header: 'Status', sortable: true },
  ];
  const [sorting, setSorting] = createSignal<{ id: string; desc: boolean }[]>([]);
  return (
    <DataTable data={rows} columns={columns} sorting={sorting()} onSortChange={setSorting} selectionMode="multiple" stickyHeader />
  );
}
ts
import '@rozie-ui/data-table-lit';

// <rozie-data-table> is a custom element. Set `data`/`columns` as properties
// and listen for the change events (`sort-change`, `filter-change`, …).
const el = document.querySelector('rozie-data-table');
el.data = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];
el.columns = [
    { field: 'name',   header: 'Name',   sortable: true, filterable: true },
    { field: 'email',  header: 'Email' },
    { field: 'status', header: 'Status', sortable: true },
  ];
el.addEventListener('sort-change', (e) => {
  console.log('sorting', e.detail);
});

Declarative <Column> children + a custom cell

tsx
import { DataTable, Column } from '@rozie-ui/data-table-react';

export function Demo() {
  const rows = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];
  // One cell renderer on <DataTable>, dispatched by columnId — it works the same
  // whether columns are declared as <Column> children or via :columns.
  return (
    <DataTable
      data={rows}
      selectionMode="multiple"
      stickyHeader
      renderCell={({ columnId, value }) =>
        columnId === 'status' ? <StatusBadge status={value} /> : value
      }
    >
      <Column field="name" header="Name" sortable filterable />
      <Column field="email" header="Email" />
      <Column field="status" header="Status" sortable />
    </DataTable>
  );
}
vue
<script setup lang="ts">
import { ref } from 'vue';
import DataTable, { Column } from '@rozie-ui/data-table-vue';

const rows = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];
const sorting = ref<{ id: string; desc: boolean }[]>([]);
</script>

<template>
  <DataTable :data="rows" v-model:sorting="sorting" selection-mode="multiple" sticky-header>
    <Column field="name" header="Name" :sortable="true" :filterable="true" />
    <Column field="email" header="Email" />
    <Column field="status" header="Status" :sortable="true" />

    <!-- One #cell slot on <DataTable>, dispatched by columnId (works with :columns too) -->
    <template #cell="{ columnId, value }">
      <span v-if="columnId === 'status'" class="badge">{{ value }}</span>
      <template v-else>{{ value }}</template>
    </template>
  </DataTable>
</template>
svelte
<script lang="ts">
  import DataTable, { Column } from '@rozie-ui/data-table-svelte';

  const rows = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];
  let sorting = $state<{ id: string; desc: boolean }[]>([]);
</script>

<DataTable data={rows} bind:sorting selectionMode="multiple" stickyHeader>
  <Column field="name" header="Name" sortable filterable />
  <Column field="email" header="Email" />
  <Column field="status" header="Status" sortable />

  <!-- One cell snippet on <DataTable>, dispatched by columnId -->
  {#snippet cell({ columnId, value })}
    {#if columnId === 'status'}<span class="badge">{value}</span>{:else}{value}{/if}
  {/snippet}
</DataTable>
ts
import { Component } from '@angular/core';
import { DataTable, Column } from '@rozie-ui/data-table-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [DataTable, Column],
  template: `
    <DataTable [data]="rows" [(sorting)]="sorting" selectionMode="multiple" [stickyHeader]="true">
      <Column field="name" header="Name" [sortable]="true" [filterable]="true" />
      <Column field="email" header="Email" />
      <Column field="status" header="Status" [sortable]="true" />

      <!-- One #cell template on <DataTable>, dispatched by columnId -->
      <ng-template #cell let-columnId="columnId" let-value="value">
        @if (columnId === 'status') {
          <span class="badge">{{ value }}</span>
        } @else {
          {{ value }}
        }
      </ng-template>
    </DataTable>
  `,
})
export class DemoComponent {
  rows = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];
  sorting: { id: string; desc: boolean }[] = [];
}
tsx
import { createSignal } from 'solid-js';
import { DataTable, Column } from '@rozie-ui/data-table-solid';

export function Demo() {
  const rows = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];
  const [sorting, setSorting] = createSignal<{ id: string; desc: boolean }[]>([]);
  return (
    <DataTable
      data={rows}
      sorting={sorting()}
      onSortChange={setSorting}
      selectionMode="multiple"
      stickyHeader
      cellSlot={({ columnId, value }) =>
        columnId === 'status' ? <StatusBadge status={value} /> : value
      }
    >
      <Column field="name" header="Name" sortable filterable />
      <Column field="email" header="Email" />
      <Column field="status" header="Status" sortable />
    </DataTable>
  );
}
ts
import { html, render } from 'lit';
import '@rozie-ui/data-table-lit';

const rows = [
    { id: 1, name: 'Ada Lovelace',   email: 'ada@analytical.engine',  status: 'active' },
    { id: 2, name: 'Alan Turing',    email: 'alan@bletchley.park',    status: 'active' },
    { id: 3, name: 'Grace Hopper',   email: 'grace@navy.mil',         status: 'away'   },
  ];

// Declare columns as <rozie-column> children and supply ONE cell renderer —
// a function returning a Lit template, dispatched by columnId.
render(html`
  <rozie-data-table
    .data=${rows}
    selection-mode="multiple"
    sticky-header
    .cell=${({ columnId, value }) =>
      columnId === 'status' ? html`<span class="badge">${value}</span>` : value}
  >
    <rozie-column field="name" header="Name" sortable filterable></rozie-column>
    <rozie-column field="email" header="Email"></rozie-column>
    <rozie-column field="status" header="Status" sortable></rozie-column>
  </rozie-data-table>
`, document.body);

Virtualized rows (windowing)

tsx
import { DataTable, Column } from '@rozie-ui/data-table-react';

// PROP form — bound `maxHeight` sizes the scroll container.
export function Demo() {
  const rows = Array.from({ length: 10_000 }, (_, i) => ({
    id: i + 1,
    name: `Row ${i + 1}`,
    email: `user${i + 1}@example.com`,
    status: i % 2 ? 'active' : 'away',
  }));
  return (
    <DataTable data={rows} virtual maxHeight="400px">
      <Column field="name" header="Name" />
      <Column field="email" header="Email" />
      <Column field="status" header="Status" />
    </DataTable>
  );
}

// TOKEN form — the same bound height via the CSS custom property (the prop wins
// when both are set; the token is the fallback). Tune the row estimate too:
// <DataTable data={rows} virtual estimateRowHeight={48}
//   style={{ '--rozie-data-table-max-height': '400px' } as React.CSSProperties} />
vue
<script setup lang="ts">
import DataTable, { Column } from '@rozie-ui/data-table-vue';

const rows = Array.from({ length: 10_000 }, (_, i) => ({
    id: i + 1,
    name: `Row ${i + 1}`,
    email: `user${i + 1}@example.com`,
    status: i % 2 ? 'active' : 'away',
  }));
</script>

<template>
  <!-- PROP form — bound :maxHeight sizes the scroll container. -->
  <DataTable :data="rows" :virtual="true" maxHeight="400px">
    <Column field="name" header="Name" />
    <Column field="email" header="Email" />
    <Column field="status" header="Status" />
  </DataTable>

  <!-- TOKEN form — the same height via the CSS custom property (prop wins when
       both are set; the token is the fallback). :estimateRowHeight tunes the seed. -->
  <DataTable
    :data="rows"
    :virtual="true"
    :estimateRowHeight="48"
    style="--rozie-data-table-max-height: 400px"
  >
    <Column field="name" header="Name" />
    <Column field="email" header="Email" />
    <Column field="status" header="Status" />
  </DataTable>
</template>
svelte
<script lang="ts">
  import DataTable, { Column } from '@rozie-ui/data-table-svelte';

  const rows = Array.from({ length: 10_000 }, (_, i) => ({
    id: i + 1,
    name: `Row ${i + 1}`,
    email: `user${i + 1}@example.com`,
    status: i % 2 ? 'active' : 'away',
  }));
</script>

<!-- PROP form — bound maxHeight sizes the scroll container. -->
<DataTable data={rows} virtual maxHeight="400px">
  <Column field="name" header="Name" />
  <Column field="email" header="Email" />
  <Column field="status" header="Status" />
</DataTable>

<!-- TOKEN form — the same height via the CSS custom property (prop wins when both
     are set; the token is the fallback). estimateRowHeight tunes the seed. -->
<DataTable data={rows} virtual estimateRowHeight={48} style="--rozie-data-table-max-height: 400px">
  <Column field="name" header="Name" />
  <Column field="email" header="Email" />
  <Column field="status" header="Status" />
</DataTable>
ts
import { Component } from '@angular/core';
import { DataTable, Column } from '@rozie-ui/data-table-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [DataTable, Column],
  template: `
    <!-- PROP form — bound [maxHeight] sizes the scroll container. -->
    <DataTable [data]="rows" [virtual]="true" maxHeight="400px">
      <Column field="name" header="Name" />
      <Column field="email" header="Email" />
      <Column field="status" header="Status" />
    </DataTable>

    <!-- TOKEN form — the same height via the CSS custom property (prop wins when
         both are set; the token is the fallback). [estimateRowHeight] tunes the seed. -->
    <DataTable
      [data]="rows"
      [virtual]="true"
      [estimateRowHeight]="48"
      style="--rozie-data-table-max-height: 400px"
    >
      <Column field="name" header="Name" />
      <Column field="email" header="Email" />
      <Column field="status" header="Status" />
    </DataTable>
  `,
})
export class DemoComponent {
  rows = Array.from({ length: 10_000 }, (_, i) => ({
    id: i + 1,
    name: `Row ${i + 1}`,
    email: `user${i + 1}@example.com`,
    status: i % 2 ? 'active' : 'away',
  }));
}
tsx
import { DataTable, Column } from '@rozie-ui/data-table-solid';

// PROP form — bound maxHeight sizes the scroll container.
export function Demo() {
  const rows = Array.from({ length: 10_000 }, (_, i) => ({
    id: i + 1,
    name: `Row ${i + 1}`,
    email: `user${i + 1}@example.com`,
    status: i % 2 ? 'active' : 'away',
  }));
  return (
    <DataTable data={rows} virtual maxHeight="400px">
      <Column field="name" header="Name" />
      <Column field="email" header="Email" />
      <Column field="status" header="Status" />
    </DataTable>
  );
}

// TOKEN form — the same height via the CSS custom property (the prop wins when
// both are set; the token is the fallback). estimateRowHeight tunes the seed:
// <DataTable data={rows} virtual estimateRowHeight={48}
//   style={{ '--rozie-data-table-max-height': '400px' }} />
ts
import { html, render } from 'lit';
import '@rozie-ui/data-table-lit';

const rows = Array.from({ length: 10_000 }, (_, i) => ({
    id: i + 1,
    name: `Row ${i + 1}`,
    email: `user${i + 1}@example.com`,
    status: i % 2 ? 'active' : 'away',
  }));

// PROP form — the `max-height` attribute sizes the scroll container.
render(html`
  <rozie-data-table .data=${rows} virtual max-height="400px">
    <rozie-column field="name" header="Name"></rozie-column>
    <rozie-column field="email" header="Email"></rozie-column>
    <rozie-column field="status" header="Status"></rozie-column>
  </rozie-data-table>
`, document.body);

// TOKEN form — the same height via the CSS custom property (the prop wins when
// both are set; the token is the fallback). `estimate-row-height` tunes the seed:
//   <rozie-data-table .data=${rows} virtual estimate-row-height="48"
//     style="--rozie-data-table-max-height: 400px"> … </rozie-data-table>

Editable cells (inline edit + validation)

tsx
import { useState } from 'react';
import { DataTable, Column } from '@rozie-ui/data-table-react';

export function Demo() {
  // The component OWNS edit state — bind ONE model ('data') + listen for commits.
  const [rows, setRows] = useState([
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ]);
  return (
    <DataTable
      interactionMode="grid"
      data={rows}
      onDataChange={setRows}
      onCellEditCommit={({ rowId, columnId, oldValue, newValue }) =>
        console.log('cell commit', rowId, columnId, oldValue, '→', newValue)
      }
      onRowEditCommit={({ rowId, changes }) => console.log('row commit', rowId, changes)}
      // The #editor scoped slot is a render prop on React (the documented edge).
      renderEditor={({ columnId, value, commit, cancel }) =>
        columnId === 'score' ? (
          <span>
            <button onClick={() => commit(Number(value) - 1)}>−</button>
            <button onClick={() => commit(Number(value) + 1)}>+</button>
            <button onClick={cancel}>esc</button>
          </span>
        ) : null
      }
    >
      <Column field="name" header="Name" editable editor="text" />
      <Column field="qty" header="Qty" editable editor="number"
        validate={(value) => Number(value) >= 0 || 'must be >= 0'} />
      <Column field="status" header="Status" editable editor="select" editorOptions={[
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ]} />
      <Column field="active" header="Active" editable editor="checkbox" />
      <Column field="score" header="Score" editable editor="custom" />
    </DataTable>
  );
}
vue
<script setup lang="ts">
import { ref } from 'vue';
import DataTable, { Column } from '@rozie-ui/data-table-vue';

// The component OWNS edit state — bind ONE model (v-model:data) + listen for commits.
const rows = ref([
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ]);
const statusOptions = [
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ];
const validateQty = (value: unknown) => Number(value) >= 0 || 'must be >= 0';
</script>

<template>
  <DataTable
    interaction-mode="grid"
    v-model:data="rows"
    @cell-edit-commit="(p) => console.log('cell commit', p)"
    @row-edit-commit="(p) => console.log('row commit', p)"
  >
    <Column field="name" header="Name" :editable="true" editor="text" />
    <Column field="qty" header="Qty" :editable="true" editor="number" :validate="validateQty" />
    <Column field="status" header="Status" :editable="true" editor="select" :editorOptions="statusOptions" />
    <Column field="active" header="Active" :editable="true" editor="checkbox" />
    <Column field="score" header="Score" :editable="true" editor="custom" />

    <!-- The #editor scoped slot replaces the built-in editor for one column. -->
    <template #editor="{ columnId, value, commit, cancel }">
      <span v-if="columnId === 'score'">
        <button type="button" @click="commit(Number(value) - 1)">−</button>
        <button type="button" @click="commit(Number(value) + 1)">+</button>
        <button type="button" @click="cancel()">esc</button>
      </span>
    </template>
  </DataTable>
</template>
svelte
<script lang="ts">
  import DataTable, { Column } from '@rozie-ui/data-table-svelte';

  // The component OWNS edit state — bind ONE model (bind:data) + listen for commits.
  let rows = $state([
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ]);
  const statusOptions = [
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ];
  const validateQty = (value: unknown) => Number(value) >= 0 || 'must be >= 0';
</script>

<DataTable
  interactionMode="grid"
  bind:data={rows}
  oncelleditcommit={(p) => console.log('cell commit', p)}
  onroweditcommit={(p) => console.log('row commit', p)}
>
  <Column field="name" header="Name" editable editor="text" />
  <Column field="qty" header="Qty" editable editor="number" validate={validateQty} />
  <Column field="status" header="Status" editable editor="select" editorOptions={statusOptions} />
  <Column field="active" header="Active" editable editor="checkbox" />
  <Column field="score" header="Score" editable editor="custom" />

  <!-- The #editor scoped slot is a snippet on Svelte; it replaces the built-in editor. -->
  {#snippet editor({ columnId, value, commit, cancel })}
    {#if columnId === 'score'}
      <span>
        <button type="button" onclick={() => commit(Number(value) - 1)}>−</button>
        <button type="button" onclick={() => commit(Number(value) + 1)}>+</button>
        <button type="button" onclick={() => cancel()}>esc</button>
      </span>
    {/if}
  {/snippet}
</DataTable>
ts
import { Component } from '@angular/core';
import { DataTable, Column } from '@rozie-ui/data-table-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [DataTable, Column],
  template: `
    <!-- The component OWNS edit state — bind ONE model [(data)] + listen for commits. -->
    <DataTable
      interactionMode="grid"
      [(data)]="rows"
      (cell-edit-commit)="onCellCommit($event)"
      (row-edit-commit)="onRowCommit($event)"
    >
      <Column field="name" header="Name" [editable]="true" editor="text" />
      <Column field="qty" header="Qty" [editable]="true" editor="number" [validate]="validateQty" />
      <Column field="status" header="Status" [editable]="true" editor="select" [editorOptions]="statusOptions" />
      <Column field="active" header="Active" [editable]="true" editor="checkbox" />
      <Column field="score" header="Score" [editable]="true" editor="custom" />

      <!-- The #editor scoped slot is an ng-template; it replaces the built-in editor. -->
      <ng-template #editor let-columnId="columnId" let-value="value" let-commit="commit" let-cancel="cancel">
        @if (columnId === 'score') {
          <span>
            <button type="button" (click)="commit(+value - 1)">−</button>
            <button type="button" (click)="commit(+value + 1)">+</button>
            <button type="button" (click)="cancel()">esc</button>
          </span>
        }
      </ng-template>
    </DataTable>
  `,
})
export class DemoComponent {
  rows = [
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ];
  statusOptions = [
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ];
  validateQty = (value: unknown) => Number(value) >= 0 || 'must be >= 0';
  onCellCommit(p: unknown) { console.log('cell commit', p); }
  onRowCommit(p: unknown) { console.log('row commit', p); }
}
tsx
import { createSignal } from 'solid-js';
import { DataTable, Column } from '@rozie-ui/data-table-solid';

export function Demo() {
  // The component OWNS edit state — bind ONE model ('data') + listen for commits.
  const [rows, setRows] = createSignal([
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ]);
  const statusOptions = [
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ];
  return (
    <DataTable
      interactionMode="grid"
      data={rows()}
      onDataChange={setRows}
      onCellEditCommit={(p) => console.log('cell commit', p)}
      onRowEditCommit={(p) => console.log('row commit', p)}
      // The #editor scoped slot is a render prop on Solid (the documented edge).
      editorSlot={({ columnId, value, commit, cancel }) =>
        columnId === 'score' ? (
          <span>
            <button onClick={() => commit(Number(value) - 1)}>−</button>
            <button onClick={() => commit(Number(value) + 1)}>+</button>
            <button onClick={() => cancel()}>esc</button>
          </span>
        ) : null
      }
    >
      <Column field="name" header="Name" editable editor="text" />
      <Column field="qty" header="Qty" editable editor="number"
        validate={(value) => Number(value) >= 0 || 'must be >= 0'} />
      <Column field="status" header="Status" editable editor="select" editorOptions={statusOptions} />
      <Column field="active" header="Active" editable editor="checkbox" />
      <Column field="score" header="Score" editable editor="custom" />
    </DataTable>
  );
}
ts
import { html, render } from 'lit';
import '@rozie-ui/data-table-lit';

// The component OWNS edit state — set the `data` property + listen for commits.
let rows = [
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ];
const statusOptions = [
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ];
const validateQty = (value: unknown) => Number(value) >= 0 || 'must be >= 0';

render(html`
  <rozie-data-table
    interaction-mode="grid"
    .data=${rows}
    @data-change=${(e: CustomEvent) => { rows = e.detail; }}
    @cell-edit-commit=${(e: CustomEvent) => console.log('cell commit', e.detail)}
    @row-edit-commit=${(e: CustomEvent) => console.log('row commit', e.detail)}
    .editor=${({ columnId, value, commit, cancel }) =>
      columnId === 'score'
        ? html`<span>
            <button @click=${() => commit(Number(value) - 1)}>−</button>
            <button @click=${() => commit(Number(value) + 1)}>+</button>
            <button @click=${() => cancel()}>esc</button>
          </span>`
        : null}
  >
    <rozie-column field="name" header="Name" editable editor="text"></rozie-column>
    <rozie-column field="qty" header="Qty" editable editor="number" .validate=${validateQty}></rozie-column>
    <rozie-column field="status" header="Status" editable editor="select" .editorOptions=${statusOptions}></rozie-column>
    <rozie-column field="active" header="Active" editable editor="checkbox"></rozie-column>
    <rozie-column field="score" header="Score" editable editor="custom"></rozie-column>
  </rozie-data-table>
`, document.body);

Expandable rows (#detail slot + nested sub-rows)

tsx
import { useState } from 'react';
import { DataTable, Column } from '@rozie-ui/data-table-react';

export function Demo() {
  // `expandable` opts the table into getExpandedRowModel + the auto-injected chevron
  // column. `getSubRows` yields depth-indented child rows; the #detail slot renders an
  // arbitrary panel under any open row. The two-way `expanded` set keeps MULTIPLE rows open.
  const rows = [
    { id: 1, name: 'Engineering', headcount: 12, children: [
      { id: 11, name: 'Frontend', headcount: 5 },
      { id: 12, name: 'Backend',  headcount: 7 },
    ] },
    { id: 2, name: 'Sales', headcount: 8 },
  ];
  const [expanded, setExpanded] = useState<Record<string, boolean>>({});
  return (
    <DataTable
      data={rows}
      expandable
      expanded={expanded}
      onExpandChange={setExpanded}             // the event is `expand-change` → onExpandChange
      getSubRows={(row) => row.children}       // depth-indented nested rows (pattern b)
      // The #detail scoped slot is a render prop on React (the documented edge).
      renderDetail={({ row }) => <aside className="detail">More about {row.name}</aside>}
    >
      <Column field="name" header="Name" />
      <Column field="headcount" header="Headcount" />
    </DataTable>
  );
}
vue
<script setup lang="ts">
import { ref } from 'vue';
import DataTable, { Column } from '@rozie-ui/data-table-vue';

const rows = [
    { id: 1, name: 'Engineering', headcount: 12, children: [
      { id: 11, name: 'Frontend', headcount: 5 },
      { id: 12, name: 'Backend',  headcount: 7 },
    ] },
    { id: 2, name: 'Sales', headcount: 8 },
  ];
const expanded = ref<Record<string, boolean>>({});
const getSubRows = (row: { children?: unknown[] }) => row.children;
</script>

<template>
  <!-- expandable opts in; v-model:expanded keeps MULTIPLE rows open; getSubRows yields
       depth-indented child rows; the #detail scoped slot renders a panel under each open row. -->
  <DataTable :data="rows" :expandable="true" v-model:expanded="expanded" :getSubRows="getSubRows">
    <Column field="name" header="Name" />
    <Column field="headcount" header="Headcount" />

    <template #detail="{ row }">
      <aside class="detail">More about {{ row.name }}</aside>
    </template>
  </DataTable>
</template>
svelte
<script lang="ts">
  import DataTable, { Column } from '@rozie-ui/data-table-svelte';

  const rows = [
    { id: 1, name: 'Engineering', headcount: 12, children: [
      { id: 11, name: 'Frontend', headcount: 5 },
      { id: 12, name: 'Backend',  headcount: 7 },
    ] },
    { id: 2, name: 'Sales', headcount: 8 },
  ];
  // bind:expanded keeps MULTIPLE rows open; getSubRows yields depth-indented child rows.
  let expanded = $state<Record<string, boolean>>({});
  const getSubRows = (row: { children?: unknown[] }) => row.children;
</script>

<DataTable data={rows} expandable bind:expanded {getSubRows}>
  <Column field="name" header="Name" />
  <Column field="headcount" header="Headcount" />

  <!-- The #detail scoped slot is a snippet on Svelte; it renders under each open row. -->
  {#snippet detail({ row })}
    <aside class="detail">More about {row.name}</aside>
  {/snippet}
</DataTable>
ts
import { Component } from '@angular/core';
import { DataTable, Column } from '@rozie-ui/data-table-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [DataTable, Column],
  template: `
    <!-- expandable opts in; [(expanded)] keeps MULTIPLE rows open; getSubRows yields nested rows. -->
    <DataTable [data]="rows" [expandable]="true" [(expanded)]="expanded" [getSubRows]="getSubRows">
      <Column field="name" header="Name" />
      <Column field="headcount" header="Headcount" />

      <!-- The #detail scoped slot is an ng-template receiving { row }. -->
      <ng-template #detail let-row="row">
        <aside class="detail">More about {{ row.name }}</aside>
      </ng-template>
    </DataTable>
  `,
})
export class DemoComponent {
  rows = [
    { id: 1, name: 'Engineering', headcount: 12, children: [
      { id: 11, name: 'Frontend', headcount: 5 },
      { id: 12, name: 'Backend',  headcount: 7 },
    ] },
    { id: 2, name: 'Sales', headcount: 8 },
  ];
  expanded: Record<string, boolean> = {};
  getSubRows = (row: { children?: unknown[] }) => row.children;
}
tsx
import { createSignal } from 'solid-js';
import { DataTable, Column } from '@rozie-ui/data-table-solid';

export function Demo() {
  // expandable opts in; the two-way `expanded` set keeps MULTIPLE rows open; getSubRows
  // yields depth-indented child rows; the #detail slot renders a panel under any open row.
  const rows = [
    { id: 1, name: 'Engineering', headcount: 12, children: [
      { id: 11, name: 'Frontend', headcount: 5 },
      { id: 12, name: 'Backend',  headcount: 7 },
    ] },
    { id: 2, name: 'Sales', headcount: 8 },
  ];
  const [expanded, setExpanded] = createSignal<Record<string, boolean>>({});
  return (
    <DataTable
      data={rows}
      expandable
      expanded={expanded()}
      onExpandChange={setExpanded}             // the event is `expand-change` → onExpandChange
      getSubRows={(row) => row.children}       // depth-indented nested rows
      // The #detail scoped slot is a render prop on Solid (the documented edge).
      detailSlot={({ row }) => <aside class="detail">More about {row.name}</aside>}
    >
      <Column field="name" header="Name" />
      <Column field="headcount" header="Headcount" />
    </DataTable>
  );
}
ts
import { html, render } from 'lit';
import '@rozie-ui/data-table-lit';

const rows = [
    { id: 1, name: 'Engineering', headcount: 12, children: [
      { id: 11, name: 'Frontend', headcount: 5 },
      { id: 12, name: 'Backend',  headcount: 7 },
    ] },
    { id: 2, name: 'Sales', headcount: 8 },
  ];

// expandable opts in; listen for `expand-change`; getSubRows yields depth-indented nested
// rows; the #detail scoped slot is the `.detail` property (a function returning a template).
render(html`
  <rozie-data-table
    .data=${rows}
    expandable
    .getSubRows=${(row: { children?: unknown[] }) => row.children}
    .detail=${({ row }) => html`<aside class="detail">More about ${row.name}</aside>`}
    @expand-change=${(e: CustomEvent) => console.log('expanded', e.detail)}
  >
    <rozie-column field="name" header="Name"></rozie-column>
    <rozie-column field="headcount" header="Headcount"></rozie-column>
  </rozie-data-table>
`, document.body);

Grouping + aggregation (headless #groupBar)

tsx
import { useState } from 'react';
import { DataTable, Column } from '@rozie-ui/data-table-react';

export function Demo() {
  // `groupable` enables getGroupedRowModel. The `grouping` model is an ORDERED column-id
  // list (multi-column → nested groups). Per-column `aggregationFn` rolls leaf values up
  // into the group-header row (a built-in name OR a custom fn). #groupBar is HEADLESS —
  // you build the bar from its props; the component ships NO drag UI (D-02 retired).
  const rows = [
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ];
  const [grouping, setGrouping] = useState<string[]>([]);
  const scoreRange = (columnId: string, leafRows: { getValue: (id: string) => number }[]) => {
    const v = leafRows.map((r) => Number(r.getValue(columnId)));
    return v.length ? Math.max(...v) - Math.min(...v) : 0;
  };
  return (
    <DataTable
      data={rows}
      groupable
      grouping={grouping}
      onGroupChange={setGrouping}              // the event is `group-change` → onGroupChange
      // The #groupBar scoped slot is a render prop on React (the documented edge).
      renderGroupBar={({ grouping, groupableColumns, applyGrouping, clearGrouping }) => (
        <div>
          <button onClick={() => applyGrouping(['region', 'category'])}>Group region → category</button>
          <button onClick={() => clearGrouping()}>Clear</button>
          <span>{grouping.join(' → ') || 'ungrouped'} ({groupableColumns.length} groupable)</span>
        </div>
      )}
    >
      <Column field="region" header="Region" />
      <Column field="category" header="Category" />
      <Column field="units" header="Units" aggregationFn="sum" />
      <Column field="score" header="Score" aggregationFn={scoreRange} />
    </DataTable>
  );
}
vue
<script setup lang="ts">
import { ref } from 'vue';
import DataTable, { Column } from '@rozie-ui/data-table-vue';

const rows = [
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ];
const grouping = ref<string[]>([]);
// A custom per-column aggregation (range = max − min) over the group's leaf rows.
const scoreRange = (columnId: string, leafRows: { getValue: (id: string) => number }[]) => {
  const v = leafRows.map((r) => Number(r.getValue(columnId)));
  return v.length ? Math.max(...v) - Math.min(...v) : 0;
};
</script>

<template>
  <!-- groupable enables grouping; the model is an ORDERED column-id list; aggregationFn
       rolls leaf values into the group header. The event is `group-change`. -->
  <DataTable :data="rows" :groupable="true" v-model:grouping="grouping" @group-change="(g) => console.log('grouping', g)">
    <Column field="region" header="Region" />
    <Column field="category" header="Category" />
    <Column field="units" header="Units" aggregationFn="sum" />
    <Column field="score" header="Score" :aggregationFn="scoreRange" />

    <!-- #groupBar is HEADLESS — build the bar from its props (NO built-in drag UI). -->
    <template #groupBar="{ grouping, groupableColumns, applyGrouping, clearGrouping }">
      <div class="group-bar">
        <button type="button" @click="applyGrouping(['region', 'category'])">Group region → category</button>
        <button type="button" @click="clearGrouping()">Clear</button>
        <span>{{ grouping.join(' → ') || 'ungrouped' }} ({{ groupableColumns.length }} groupable)</span>
      </div>
    </template>
  </DataTable>
</template>
svelte
<script lang="ts">
  import DataTable, { Column } from '@rozie-ui/data-table-svelte';

  const rows = [
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ];
  let grouping = $state<string[]>([]);
  const scoreRange = (columnId: string, leafRows: { getValue: (id: string) => number }[]) => {
    const v = leafRows.map((r) => Number(r.getValue(columnId)));
    return v.length ? Math.max(...v) - Math.min(...v) : 0;
  };
</script>

<!-- groupable enables grouping; the model is an ORDERED column-id list; the event is
     `group-change` → ongroupchange. aggregationFn rolls leaf values into the group header. -->
<DataTable data={rows} groupable bind:grouping ongroupchange={(g) => console.log('grouping', g)}>
  <Column field="region" header="Region" />
  <Column field="category" header="Category" />
  <Column field="units" header="Units" aggregationFn="sum" />
  <Column field="score" header="Score" aggregationFn={scoreRange} />

  <!-- #groupBar is HEADLESS — build the bar from its props (NO built-in drag UI). -->
  {#snippet groupBar({ grouping, groupableColumns, applyGrouping, clearGrouping })}
    <div class="group-bar">
      <button type="button" onclick={() => applyGrouping(['region', 'category'])}>Group region → category</button>
      <button type="button" onclick={() => clearGrouping()}>Clear</button>
      <span>{grouping.join(' → ') || 'ungrouped'} ({groupableColumns.length} groupable)</span>
    </div>
  {/snippet}
</DataTable>
ts
import { Component } from '@angular/core';
import { DataTable, Column } from '@rozie-ui/data-table-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [DataTable, Column],
  template: `
    <!-- groupable enables grouping; [(grouping)] is an ORDERED column-id list; the event
         is `group-change`. aggregationFn rolls leaf values into the group header. -->
    <DataTable [data]="rows" [groupable]="true" [(grouping)]="grouping" (group-change)="onGroupChange($event)">
      <Column field="region" header="Region" />
      <Column field="category" header="Category" />
      <Column field="units" header="Units" aggregationFn="sum" />
      <Column field="score" header="Score" [aggregationFn]="scoreRange" />

      <!-- #groupBar is HEADLESS — build the bar from its props (NO built-in drag UI). -->
      <ng-template #groupBar let-grouping="grouping" let-groupableColumns="groupableColumns" let-applyGrouping="applyGrouping" let-clearGrouping="clearGrouping">
        <div class="group-bar">
          <button type="button" (click)="applyGrouping(['region', 'category'])">Group region → category</button>
          <button type="button" (click)="clearGrouping()">Clear</button>
          <span>{{ grouping.join(' → ') || 'ungrouped' }} ({{ groupableColumns.length }} groupable)</span>
        </div>
      </ng-template>
    </DataTable>
  `,
})
export class DemoComponent {
  rows = [
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ];
  grouping: string[] = [];
  scoreRange = (columnId: string, leafRows: { getValue: (id: string) => number }[]) => {
    const v = leafRows.map((r) => Number(r.getValue(columnId)));
    return v.length ? Math.max(...v) - Math.min(...v) : 0;
  };
  onGroupChange(g: string[]) { console.log('grouping', g); }
}
tsx
import { createSignal } from 'solid-js';
import { DataTable, Column } from '@rozie-ui/data-table-solid';

export function Demo() {
  // groupable enables grouping; the `grouping` model is an ORDERED column-id list;
  // aggregationFn rolls leaf values into the group header. #groupBar is HEADLESS (no drag).
  const rows = [
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ];
  const [grouping, setGrouping] = createSignal<string[]>([]);
  const scoreRange = (columnId: string, leafRows: { getValue: (id: string) => number }[]) => {
    const v = leafRows.map((r) => Number(r.getValue(columnId)));
    return v.length ? Math.max(...v) - Math.min(...v) : 0;
  };
  return (
    <DataTable
      data={rows}
      groupable
      grouping={grouping()}
      onGroupChange={setGrouping}              // the event is `group-change` → onGroupChange
      // The #groupBar scoped slot is a render prop on Solid (the documented edge).
      groupBarSlot={({ grouping, groupableColumns, applyGrouping, clearGrouping }) => (
        <div>
          <button onClick={() => applyGrouping(['region', 'category'])}>Group region → category</button>
          <button onClick={() => clearGrouping()}>Clear</button>
          <span>{grouping.join(' → ') || 'ungrouped'} ({groupableColumns.length} groupable)</span>
        </div>
      )}
    >
      <Column field="region" header="Region" />
      <Column field="category" header="Category" />
      <Column field="units" header="Units" aggregationFn="sum" />
      <Column field="score" header="Score" aggregationFn={scoreRange} />
    </DataTable>
  );
}
ts
import { html, render } from 'lit';
import '@rozie-ui/data-table-lit';

const rows = [
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ];
const scoreRange = (columnId: string, leafRows: { getValue: (id: string) => number }[]) => {
  const v = leafRows.map((r) => Number(r.getValue(columnId)));
  return v.length ? Math.max(...v) - Math.min(...v) : 0;
};

// groupable enables grouping; listen for `group-change`; #groupBar is the headless
// `.groupBar` property (NO built-in drag UI) — build the bar from its props.
render(html`
  <rozie-data-table
    .data=${rows}
    groupable
    @group-change=${(e: CustomEvent) => console.log('grouping', e.detail)}
    .groupBar=${({ grouping, groupableColumns, applyGrouping, clearGrouping }) => html`
      <div>
        <button @click=${() => applyGrouping(['region', 'category'])}>Group region → category</button>
        <button @click=${() => clearGrouping()}>Clear</button>
        <span>${grouping.join(' → ') || 'ungrouped'} (${groupableColumns.length} groupable)</span>
      </div>`}
  >
    <rozie-column field="region" header="Region"></rozie-column>
    <rozie-column field="category" header="Category"></rozie-column>
    <rozie-column field="units" header="Units" .aggregationFn=${'sum'}></rozie-column>
    <rozie-column field="score" header="Score" .aggregationFn=${scoreRange}></rozie-column>
  </rozie-data-table>
`, document.body);

Faceted filtering exposure (headless #filter)

tsx
import { useRef, useState } from 'react';
import { DataTable, Column, type DataTableHandle } from '@rozie-ui/data-table-react';

export function Demo() {
  // Faceting is HEADLESS + read-only: NO event, NO built-in facet control. The #filter
  // scoped slot hands you the cross-filtered `uniqueValues` (keys only) + numeric `minMax`;
  // you build the checkbox list / range slider and drive `columnFilters`. The
  // getFacetedUniqueValues / getFacetedMinMaxValues handle verbs read the same data back.
  const rows = [
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ];
  const [columnFilters, setColumnFilters] = useState<{ id: string; value: unknown }[]>([]);
  const tbl = useRef<DataTableHandle>(null);
  return (
    <DataTable
      ref={tbl}
      data={rows}
      columnFilters={columnFilters}
      onFilterChange={(p) => p.columnFilters && setColumnFilters(p.columnFilters)}
      // The #filter scoped slot is a render prop on React (the documented edge).
      renderFilter={({ columnId, uniqueValues, minMax }) =>
        columnId === 'category' ? (
          <fieldset>
            {uniqueValues.map((v) => (
              <label key={String(v)}><input type="checkbox" /> {String(v)}</label>
            ))}
          </fieldset>
        ) : (
          <input type="range" min={minMax?.[0]} max={minMax?.[1]} />
        )
      }
    >
      <Column field="name" header="Name" />
      <Column field="category" header="Category" filterable />
      <Column field="price" header="Price" filterable />
    </DataTable>
  );
}
vue
<script setup lang="ts">
import { ref } from 'vue';
import DataTable, { Column } from '@rozie-ui/data-table-vue';

const rows = [
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ];
const columnFilters = ref<{ id: string; value: unknown }[]>([]);
</script>

<template>
  <!-- Faceting is HEADLESS + read-only (NO event, NO built-in control). The #filter slot
       hands you `uniqueValues` (keys, cross-filtered) + numeric `minMax`; build the UI and
       drive v-model:columnFilters. -->
  <DataTable :data="rows" v-model:columnFilters="columnFilters">
    <Column field="name" header="Name" />
    <Column field="category" header="Category" :filterable="true" />
    <Column field="price" header="Price" :filterable="true" />

    <template #filter="{ columnId, uniqueValues, minMax }">
      <fieldset v-if="columnId === 'category'">
        <label v-for="v in uniqueValues" :key="v"><input type="checkbox" /> {{ v }}</label>
      </fieldset>
      <input v-else type="range" :min="minMax[0]" :max="minMax[1]" />
    </template>
  </DataTable>
</template>
svelte
<script lang="ts">
  import DataTable, { Column } from '@rozie-ui/data-table-svelte';

  // Faceting is HEADLESS + read-only (NO event, NO built-in control). The #filter slot
  // hands you `uniqueValues` (keys, cross-filtered) + numeric `minMax`.
  const rows = [
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ];
  let columnFilters = $state<{ id: string; value: unknown }[]>([]);
</script>

<DataTable data={rows} bind:columnFilters>
  <Column field="name" header="Name" />
  <Column field="category" header="Category" filterable />
  <Column field="price" header="Price" filterable />

  {#snippet filter({ columnId, uniqueValues, minMax })}
    {#if columnId === 'category'}
      <fieldset>
        {#each uniqueValues as v}<label><input type="checkbox" /> {v}</label>{/each}
      </fieldset>
    {:else}
      <input type="range" min={minMax[0]} max={minMax[1]} />
    {/if}
  {/snippet}
</DataTable>
ts
import { Component } from '@angular/core';
import { DataTable, Column } from '@rozie-ui/data-table-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [DataTable, Column],
  template: `
    <!-- Faceting is HEADLESS + read-only (NO event, NO built-in control). The #filter slot
         hands you `uniqueValues` (keys, cross-filtered) + numeric `minMax`. -->
    <DataTable [data]="rows" [(columnFilters)]="columnFilters">
      <Column field="name" header="Name" />
      <Column field="category" header="Category" [filterable]="true" />
      <Column field="price" header="Price" [filterable]="true" />

      <ng-template #filter let-columnId="columnId" let-uniqueValues="uniqueValues" let-minMax="minMax">
        @if (columnId === 'category') {
          <fieldset>
            @for (v of uniqueValues; track v) { <label><input type="checkbox" /> {{ v }}</label> }
          </fieldset>
        } @else {
          <input type="range" [min]="minMax[0]" [max]="minMax[1]" />
        }
      </ng-template>
    </DataTable>
  `,
})
export class DemoComponent {
  rows = [
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ];
  columnFilters: { id: string; value: unknown }[] = [];
}
tsx
import { createSignal } from 'solid-js';
import { DataTable, Column } from '@rozie-ui/data-table-solid';

export function Demo() {
  // Faceting is HEADLESS + read-only (NO event, NO built-in control). The #filter slot
  // hands you `uniqueValues` (keys, cross-filtered) + numeric `minMax`.
  const rows = [
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ];
  const [columnFilters, setColumnFilters] = createSignal<{ id: string; value: unknown }[]>([]);
  return (
    <DataTable
      data={rows}
      columnFilters={columnFilters()}
      onFilterChange={(p) => p.columnFilters && setColumnFilters(p.columnFilters)}
      // The #filter scoped slot is a render prop on Solid (the documented edge).
      filterSlot={({ columnId, uniqueValues, minMax }) =>
        columnId === 'category' ? (
          <fieldset>
            {uniqueValues.map((v) => <label><input type="checkbox" /> {String(v)}</label>)}
          </fieldset>
        ) : (
          <input type="range" min={minMax?.[0]} max={minMax?.[1]} />
        )
      }
    >
      <Column field="name" header="Name" />
      <Column field="category" header="Category" filterable />
      <Column field="price" header="Price" filterable />
    </DataTable>
  );
}
ts
import { html, render } from 'lit';
import '@rozie-ui/data-table-lit';

const rows = [
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ];

// Faceting is HEADLESS + read-only (NO event, NO built-in control). The #filter slot is the
// `.filter` property; it receives `uniqueValues` (keys, cross-filtered) + numeric `minMax`.
render(html`
  <rozie-data-table
    .data=${rows}
    .filter=${({ columnId, uniqueValues, minMax }) =>
      columnId === 'category'
        ? html`<fieldset>${uniqueValues.map((v) => html`<label><input type="checkbox" /> ${v}</label>`)}</fieldset>`
        : html`<input type="range" min=${minMax?.[0]} max=${minMax?.[1]} />`}
  >
    <rozie-column field="name" header="Name"></rozie-column>
    <rozie-column field="category" header="Category" filterable></rozie-column>
    <rozie-column field="price" header="Price" filterable></rozie-column>
  </rozie-data-table>
`, document.body);

Drop-in editor components (#editor)

tsx
import { useState } from 'react';
import {
  DataTable, Column,
  EditorText, EditorNumber, EditorSelect, EditorCheckbox, EditorDate,
} from '@rozie-ui/data-table-react';

export function Demo() {
  // OPT-IN drop-in editors fill the #editor slot — DataTable stays the headless
  // DEFAULT; the editors are additive named exports. Mark each column editor="custom"
  // (the drop-in owns rendering) and dispatch by columnId. Each editor takes the slot
  // scope as props ({ columnId, column, row, value, commit, cancel }); EditorSelect
  // also takes `options`. Use them as-is, or fork one as a template.
  const [rows, setRows] = useState([
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ]);
  const statusOptions = [
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ];
  return (
    <DataTable
      interactionMode="grid"
      data={rows}
      onDataChange={setRows}
      onCellEditCommit={(p) => console.log('cell commit', p)}
      // The #editor scoped slot is a render prop on React (the documented edge).
      renderEditor={(scope) => {
        switch (scope.columnId) {
          case 'name':   return <EditorText {...scope} />;
          case 'qty':    return <EditorNumber {...scope} />;
          case 'status': return <EditorSelect {...scope} options={statusOptions} />;
          case 'active': return <EditorCheckbox {...scope} />;
          case 'score':  return <EditorDate {...scope} />;
          default:       return null;
        }
      }}
    >
      <Column field="name" header="Name" editable editor="custom" />
      <Column field="qty" header="Qty" editable editor="custom" />
      <Column field="status" header="Status" editable editor="custom" />
      <Column field="active" header="Active" editable editor="custom" />
      <Column field="score" header="Score" editable editor="custom" />
    </DataTable>
  );
}
vue
<script setup lang="ts">
import { ref } from 'vue';
import DataTable, {
  Column, EditorText, EditorNumber, EditorSelect, EditorCheckbox, EditorDate,
} from '@rozie-ui/data-table-vue';

// OPT-IN drop-in editors fill the #editor slot — DataTable stays the headless DEFAULT
// export; the editors are additive named exports. v-bind the whole slot scope through
// to each drop-in ({ columnId, column, row, value, commit, cancel }); EditorSelect also
// takes :options. Use them as-is, or fork one as a template.
const rows = ref([
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ]);
const statusOptions = [
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ];
</script>

<template>
  <DataTable
    interaction-mode="grid"
    v-model:data="rows"
    @cell-edit-commit="(p) => console.log('cell commit', p)"
  >
    <Column field="name" header="Name" :editable="true" editor="custom" />
    <Column field="qty" header="Qty" :editable="true" editor="custom" />
    <Column field="status" header="Status" :editable="true" editor="custom" />
    <Column field="active" header="Active" :editable="true" editor="custom" />
    <Column field="score" header="Score" :editable="true" editor="custom" />

    <!-- One #editor slot, dispatched by columnId, wiring the drop-in editors. -->
    <template #editor="scope">
      <EditorText v-if="scope.columnId === 'name'" v-bind="scope" />
      <EditorNumber v-else-if="scope.columnId === 'qty'" v-bind="scope" />
      <EditorSelect v-else-if="scope.columnId === 'status'" v-bind="scope" :options="statusOptions" />
      <EditorCheckbox v-else-if="scope.columnId === 'active'" v-bind="scope" />
      <EditorDate v-else-if="scope.columnId === 'score'" v-bind="scope" />
    </template>
  </DataTable>
</template>
svelte
<script lang="ts">
  import DataTable, {
    Column, EditorText, EditorNumber, EditorSelect, EditorCheckbox, EditorDate,
  } from '@rozie-ui/data-table-svelte';

  // OPT-IN drop-in editors fill the #editor snippet — DataTable stays the headless
  // DEFAULT export; the editors are additive named exports. Spread the snippet scope
  // through to each drop-in ({ columnId, column, row, value, commit, cancel });
  // EditorSelect also takes `options`. Use them as-is, or fork one as a template.
  let rows = $state([
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ]);
  const statusOptions = [
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ];
</script>

<DataTable
  interactionMode="grid"
  bind:data={rows}
  oncelleditcommit={(p) => console.log('cell commit', p)}
>
  <Column field="name" header="Name" editable editor="custom" />
  <Column field="qty" header="Qty" editable editor="custom" />
  <Column field="status" header="Status" editable editor="custom" />
  <Column field="active" header="Active" editable editor="custom" />
  <Column field="score" header="Score" editable editor="custom" />

  <!-- One #editor snippet, dispatched by columnId, wiring the drop-in editors. -->
  {#snippet editor(scope)}
    {#if scope.columnId === 'name'}
      <EditorText {...scope} />
    {:else if scope.columnId === 'qty'}
      <EditorNumber {...scope} />
    {:else if scope.columnId === 'status'}
      <EditorSelect {...scope} options={statusOptions} />
    {:else if scope.columnId === 'active'}
      <EditorCheckbox {...scope} />
    {:else if scope.columnId === 'score'}
      <EditorDate {...scope} />
    {/if}
  {/snippet}
</DataTable>
ts
import { Component } from '@angular/core';
import {
  DataTable, Column,
  EditorText, EditorNumber, EditorSelect, EditorCheckbox, EditorDate,
} from '@rozie-ui/data-table-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [DataTable, Column, EditorText, EditorNumber, EditorSelect, EditorCheckbox, EditorDate],
  template: `
    <!-- OPT-IN drop-in editors fill the #editor template — DataTable stays the headless
         default; the editors are additive named exports. Each takes the slot scope as
         inputs ([columnId] [column] [row] [value] [commit] [cancel]); EditorSelect also
         takes [options]. Mark each column editor="custom" so the #editor slot drives it. -->
    <DataTable
      interactionMode="grid"
      [(data)]="rows"
      (cell-edit-commit)="onCellCommit($event)"
    >
      <Column field="name" header="Name" [editable]="true" editor="custom" />
      <Column field="qty" header="Qty" [editable]="true" editor="custom" />
      <Column field="status" header="Status" [editable]="true" editor="custom" />
      <Column field="active" header="Active" [editable]="true" editor="custom" />
      <Column field="score" header="Score" [editable]="true" editor="custom" />

      <ng-template #editor let-columnId="columnId" let-column="column" let-row="row" let-value="value" let-commit="commit" let-cancel="cancel">
        @switch (columnId) {
          @case ('name') {
            <rozie-editor-text [columnId]="columnId" [column]="column" [row]="row" [value]="value" [commit]="commit" [cancel]="cancel" />
          }
          @case ('qty') {
            <rozie-editor-number [columnId]="columnId" [column]="column" [row]="row" [value]="value" [commit]="commit" [cancel]="cancel" />
          }
          @case ('status') {
            <rozie-editor-select [columnId]="columnId" [column]="column" [row]="row" [value]="value" [commit]="commit" [cancel]="cancel" [options]="statusOptions" />
          }
          @case ('active') {
            <rozie-editor-checkbox [columnId]="columnId" [column]="column" [row]="row" [value]="value" [commit]="commit" [cancel]="cancel" />
          }
          @case ('score') {
            <rozie-editor-date [columnId]="columnId" [column]="column" [row]="row" [value]="value" [commit]="commit" [cancel]="cancel" />
          }
        }
      </ng-template>
    </DataTable>
  `,
})
export class DemoComponent {
  rows = [
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ];
  statusOptions = [
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ];
  onCellCommit(p: unknown) { console.log('cell commit', p); }
}
tsx
import { createSignal, Switch, Match } from 'solid-js';
import {
  DataTable, Column,
  EditorText, EditorNumber, EditorSelect, EditorCheckbox, EditorDate,
} from '@rozie-ui/data-table-solid';

export function Demo() {
  // OPT-IN drop-in editors fill the #editor slot — DataTable stays the headless
  // default; the editors are additive named exports. Spread the slot scope through to
  // each drop-in ({ columnId, column, row, value, commit, cancel }); EditorSelect also
  // takes `options`. Use them as-is, or fork one as a template.
  const [rows, setRows] = createSignal([
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ]);
  const statusOptions = [
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ];
  return (
    <DataTable
      interactionMode="grid"
      data={rows()}
      onDataChange={setRows}
      onCellEditCommit={(p) => console.log('cell commit', p)}
      // The #editor scoped slot is a render prop on Solid (the documented edge).
      editorSlot={(scope) => (
        <Switch>
          <Match when={scope.columnId === 'name'}><EditorText {...scope} /></Match>
          <Match when={scope.columnId === 'qty'}><EditorNumber {...scope} /></Match>
          <Match when={scope.columnId === 'status'}><EditorSelect {...scope} options={statusOptions} /></Match>
          <Match when={scope.columnId === 'active'}><EditorCheckbox {...scope} /></Match>
          <Match when={scope.columnId === 'score'}><EditorDate {...scope} /></Match>
        </Switch>
      )}
    >
      <Column field="name" header="Name" editable editor="custom" />
      <Column field="qty" header="Qty" editable editor="custom" />
      <Column field="status" header="Status" editable editor="custom" />
      <Column field="active" header="Active" editable editor="custom" />
      <Column field="score" header="Score" editable editor="custom" />
    </DataTable>
  );
}
ts
import { html, render } from 'lit';
// The single side-effect import registers <rozie-data-table> AND the drop-in editor
// custom elements (<rozie-editor-text>, <rozie-editor-number>, <rozie-editor-select>,
// <rozie-editor-checkbox>, <rozie-editor-date>). DataTable stays the headless default;
// the editors are additive.
import '@rozie-ui/data-table-lit';

let rows = [
    { id: 1, name: 'Alpha', qty: 3, status: 'active',   active: true,  score: 41 },
    { id: 2, name: 'Beta',  qty: 7, status: 'archived', active: false, score: 92 },
  ];
const statusOptions = [
    { value: 'active',   label: 'Active' },
    { value: 'archived', label: 'Archived' },
    { value: 'pending',  label: 'Pending' },
  ];

// The #editor slot is the `.editor` property — a function returning a Lit template,
// dispatched by columnId. Pass the slot scope ({ columnId, column, row, value, commit,
// cancel }) as element properties; <rozie-editor-select> also takes `.options`.
render(html`
  <rozie-data-table
    interaction-mode="grid"
    .data=${rows}
    @data-change=${(e: CustomEvent) => { rows = e.detail; }}
    @cell-edit-commit=${(e: CustomEvent) => console.log('cell commit', e.detail)}
    .editor=${({ columnId, column, row, value, commit, cancel }) => {
      const p = { columnId, column, row, value, commit, cancel };
      switch (columnId) {
        case 'name':   return html`<rozie-editor-text .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel}></rozie-editor-text>`;
        case 'qty':    return html`<rozie-editor-number .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel}></rozie-editor-number>`;
        case 'status': return html`<rozie-editor-select .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel} .options=${statusOptions}></rozie-editor-select>`;
        case 'active': return html`<rozie-editor-checkbox .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel}></rozie-editor-checkbox>`;
        case 'score':  return html`<rozie-editor-date .columnId=${p.columnId} .column=${p.column} .row=${p.row} .value=${p.value} .commit=${p.commit} .cancel=${p.cancel}></rozie-editor-date>`;
        default:       return null;
      }
    }}
  >
    <rozie-column field="name" header="Name" editable editor="custom"></rozie-column>
    <rozie-column field="qty" header="Qty" editable editor="custom"></rozie-column>
    <rozie-column field="status" header="Status" editable editor="custom"></rozie-column>
    <rozie-column field="active" header="Active" editable editor="custom"></rozie-column>
    <rozie-column field="score" header="Score" editable editor="custom"></rozie-column>
  </rozie-data-table>
`, document.body);

Drop-in filter components (#filter)

tsx
import { useState } from 'react';
import {
  DataTable, Column,
  FilterText, FilterNumberRange, FilterSelect,
} from '@rozie-ui/data-table-react';

export function Demo() {
  // OPT-IN drop-in filters fill the #filter slot — DataTable stays the headless
  // DEFAULT; the filters are additive named exports. Mark each column `filterable`
  // (the #filter slot only renders for filterable columns) and dispatch by columnId.
  // Each filter takes the slot scope as props ({ columnId, column, value, setFilter });
  // FilterSelect also reads `uniqueValues`, FilterNumberRange also reads `minMax` —
  // both arrive in the slot scope, so spreading it through wires them up.
  const rows = [
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ];
  const [columnFilters, setColumnFilters] = useState<{ id: string; value: unknown }[]>([]);
  return (
    <DataTable
      data={rows}
      columnFilters={columnFilters}
      onFilterChange={(p) => p.columnFilters && setColumnFilters(p.columnFilters)}
      // The #filter scoped slot is a render prop on React (the documented edge).
      renderFilter={(scope) => {
        switch (scope.columnId) {
          case 'name':     return <FilterText {...scope} />;
          case 'category': return <FilterSelect {...scope} />;
          case 'price':    return <FilterNumberRange {...scope} />;
          default:         return null;
        }
      }}
    >
      <Column field="name" header="Name" filterable />
      <Column field="category" header="Category" filterable />
      <Column field="price" header="Price" filterable />
    </DataTable>
  );
}
vue
<script setup lang="ts">
import { ref } from 'vue';
import DataTable, {
  Column, FilterText, FilterNumberRange, FilterSelect,
} from '@rozie-ui/data-table-vue';

// OPT-IN drop-in filters fill the #filter slot — DataTable stays the headless DEFAULT
// export; the filters are additive named exports. Mark each column :filterable (the
// #filter slot only renders for filterable columns) and v-bind the whole slot scope
// through to each drop-in ({ columnId, column, value, setFilter, uniqueValues, minMax }).
const rows = ref([
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ]);
const columnFilters = ref<{ id: string; value: unknown }[]>([]);
</script>

<template>
  <DataTable v-model:data="rows" v-model:column-filters="columnFilters">
    <Column field="name" header="Name" :filterable="true" />
    <Column field="category" header="Category" :filterable="true" />
    <Column field="price" header="Price" :filterable="true" />

    <!-- One #filter slot, dispatched by columnId, wiring the drop-in filters. -->
    <template #filter="scope">
      <FilterText v-if="scope.columnId === 'name'" v-bind="scope" />
      <FilterSelect v-else-if="scope.columnId === 'category'" v-bind="scope" />
      <FilterNumberRange v-else-if="scope.columnId === 'price'" v-bind="scope" />
    </template>
  </DataTable>
</template>
svelte
<script lang="ts">
  import DataTable, {
    Column, FilterText, FilterNumberRange, FilterSelect,
  } from '@rozie-ui/data-table-svelte';

  // OPT-IN drop-in filters fill the #filter snippet — DataTable stays the headless
  // DEFAULT export; the filters are additive named exports. Mark each column filterable
  // (the #filter snippet only renders for filterable columns) and spread the snippet
  // scope ({ columnId, column, value, setFilter, uniqueValues, minMax }) through.
  let rows = $state([
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ]);
  let columnFilters = $state<{ id: string; value: unknown }[]>([]);
</script>

<DataTable bind:data={rows} bind:columnFilters>
  <Column field="name" header="Name" filterable />
  <Column field="category" header="Category" filterable />
  <Column field="price" header="Price" filterable />

  <!-- One #filter snippet, dispatched by columnId, wiring the drop-in filters. -->
  {#snippet filter(scope)}
    {#if scope.columnId === 'name'}
      <FilterText {...scope} />
    {:else if scope.columnId === 'category'}
      <FilterSelect {...scope} />
    {:else if scope.columnId === 'price'}
      <FilterNumberRange {...scope} />
    {/if}
  {/snippet}
</DataTable>
ts
import { Component } from '@angular/core';
import {
  DataTable, Column,
  FilterText, FilterNumberRange, FilterSelect,
} from '@rozie-ui/data-table-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [DataTable, Column, FilterText, FilterNumberRange, FilterSelect],
  template: `
    <!-- OPT-IN drop-in filters fill the #filter template — DataTable stays the headless
         default; the filters are additive named exports. Each takes the slot scope as
         inputs ([columnId] [column] [value] [setFilter]); FilterSelect also takes
         [uniqueValues], FilterNumberRange also takes [minMax]. Mark each column
         filterable so the #filter slot renders. -->
    <DataTable [(data)]="rows" [(columnFilters)]="columnFilters">
      <Column field="name" header="Name" [filterable]="true" />
      <Column field="category" header="Category" [filterable]="true" />
      <Column field="price" header="Price" [filterable]="true" />

      <ng-template #filter let-columnId="columnId" let-column="column" let-value="value" let-setFilter="setFilter" let-uniqueValues="uniqueValues" let-minMax="minMax">
        @switch (columnId) {
          @case ('name') {
            <rozie-filter-text [columnId]="columnId" [column]="column" [value]="value" [setFilter]="setFilter" />
          }
          @case ('category') {
            <rozie-filter-select [columnId]="columnId" [column]="column" [value]="value" [setFilter]="setFilter" [uniqueValues]="uniqueValues" />
          }
          @case ('price') {
            <rozie-filter-number-range [columnId]="columnId" [column]="column" [value]="value" [setFilter]="setFilter" [minMax]="minMax" />
          }
        }
      </ng-template>
    </DataTable>
  `,
})
export class DemoComponent {
  rows = [
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ];
  columnFilters: { id: string; value: unknown }[] = [];
}
tsx
import { createSignal, Switch, Match } from 'solid-js';
import {
  DataTable, Column,
  FilterText, FilterNumberRange, FilterSelect,
} from '@rozie-ui/data-table-solid';

export function Demo() {
  // OPT-IN drop-in filters fill the #filter slot — DataTable stays the headless
  // default; the filters are additive named exports. Mark each column filterable (the
  // #filter slot only renders for filterable columns) and spread the slot scope through
  // to each drop-in ({ columnId, column, value, setFilter, uniqueValues, minMax }).
  const rows = [
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ];
  const [columnFilters, setColumnFilters] = createSignal<{ id: string; value: unknown }[]>([]);
  return (
    <DataTable
      data={rows}
      columnFilters={columnFilters()}
      onFilterChange={(p) => p.columnFilters && setColumnFilters(p.columnFilters)}
      // The #filter scoped slot is a render prop on Solid (the documented edge).
      filterSlot={(scope) => (
        <Switch>
          <Match when={scope.columnId === 'name'}><FilterText {...scope} /></Match>
          <Match when={scope.columnId === 'category'}><FilterSelect {...scope} /></Match>
          <Match when={scope.columnId === 'price'}><FilterNumberRange {...scope} /></Match>
        </Switch>
      )}
    >
      <Column field="name" header="Name" filterable />
      <Column field="category" header="Category" filterable />
      <Column field="price" header="Price" filterable />
    </DataTable>
  );
}
ts
import { html, render } from 'lit';
// The single side-effect import registers <rozie-data-table> AND the drop-in filter
// custom elements (<rozie-filter-text>, <rozie-filter-number-range>,
// <rozie-filter-select>). DataTable stays the headless default; the filters are additive.
import '@rozie-ui/data-table-lit';

let rows = [
    { id: 1, name: 'Alpha',   category: 'Hardware', price: 30 },
    { id: 2, name: 'Beta',    category: 'Software', price: 90 },
    { id: 3, name: 'Gamma',   category: 'Hardware', price: 10 },
    { id: 4, name: 'Delta',   category: 'Service',  price: 50 },
  ];
let columnFilters: { id: string; value: unknown }[] = [];

// The #filter slot is the `.filter` property — a function returning a Lit template,
// dispatched by columnId. Pass the slot scope ({ columnId, column, value, setFilter,
// uniqueValues, minMax }) as element properties.
render(html`
  <rozie-data-table
    .data=${rows}
    .columnFilters=${columnFilters}
    @filter-change=${(e: CustomEvent) => { columnFilters = e.detail.columnFilters; }}
    .filter=${({ columnId, column, value, setFilter, uniqueValues, minMax }) => {
      const p = { columnId, column, value, setFilter, uniqueValues, minMax };
      switch (columnId) {
        case 'name':     return html`<rozie-filter-text .columnId=${p.columnId} .column=${p.column} .value=${p.value} .setFilter=${p.setFilter}></rozie-filter-text>`;
        case 'category': return html`<rozie-filter-select .columnId=${p.columnId} .column=${p.column} .value=${p.value} .setFilter=${p.setFilter} .uniqueValues=${p.uniqueValues}></rozie-filter-select>`;
        case 'price':    return html`<rozie-filter-number-range .columnId=${p.columnId} .column=${p.column} .value=${p.value} .setFilter=${p.setFilter} .minMax=${p.minMax}></rozie-filter-number-range>`;
        default:         return null;
      }
    }}
  >
    <rozie-column field="name" header="Name" filterable></rozie-column>
    <rozie-column field="category" header="Category" filterable></rozie-column>
    <rozie-column field="price" header="Price" filterable></rozie-column>
  </rozie-data-table>
`, document.body);

Drop-in group bar + detail panel (#groupBar / #detail)

tsx
import { useState } from 'react';
import {
  DataTable, Column, GroupBar, DetailPanel,
} from '@rozie-ui/data-table-react';

export function Demo() {
  // OPT-IN drop-ins: GroupBar fills the #groupBar slot (drag the column chips into the
  // zone to group; remove tokens; clear) and DetailPanel fills the #detail slot (the
  // open row as a key/value list). Both are additive named exports — DataTable stays the
  // headless DEFAULT. Forward the slot scope to each; fork either as a starter.
  const rows = [
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ];
  const [grouping, setGrouping] = useState<string[]>([]);
  const [expanded, setExpanded] = useState<Record<string, boolean>>({});
  return (
    <DataTable
      data={rows}
      groupable
      expandable
      grouping={grouping}
      onGroupChange={setGrouping}
      expanded={expanded}
      onExpandChange={setExpanded}
      // The #groupBar / #detail scoped slots are render props on React (the documented edge).
      renderGroupBar={(scope) => <GroupBar {...scope} />}
      renderDetail={(scope) => <DetailPanel {...scope} />}
    >
      <Column field="region" header="Region" />
      <Column field="category" header="Category" />
      <Column field="units" header="Units" aggregationFn="sum" />
    </DataTable>
  );
}
vue
<script setup lang="ts">
import { ref } from 'vue';
import DataTable, {
  Column, GroupBar, DetailPanel,
} from '@rozie-ui/data-table-vue';

// OPT-IN drop-ins fill the #groupBar + #detail slots — DataTable stays the headless
// DEFAULT export; both are additive named exports. v-bind the whole slot scope through
// to each (GroupBar gets { grouping, groupableColumns, applyGrouping, clearGrouping };
// DetailPanel gets { row }). Use them as-is, or fork either as a starter.
const rows = ref([
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ]);
const grouping = ref<string[]>([]);
const expanded = ref<Record<string, boolean>>({});
</script>

<template>
  <DataTable
    :data="rows"
    :groupable="true"
    :expandable="true"
    v-model:grouping="grouping"
    v-model:expanded="expanded"
  >
    <Column field="region" header="Region" />
    <Column field="category" header="Category" />
    <Column field="units" header="Units" aggregationFn="sum" />

    <!-- GroupBar fills #groupBar; DetailPanel fills #detail. -->
    <template #groupBar="scope">
      <GroupBar v-bind="scope" />
    </template>
    <template #detail="scope">
      <DetailPanel v-bind="scope" />
    </template>
  </DataTable>
</template>
svelte
<script lang="ts">
  import DataTable, {
    Column, GroupBar, DetailPanel,
  } from '@rozie-ui/data-table-svelte';

  // OPT-IN drop-ins fill the #groupBar + #detail snippets — DataTable stays the headless
  // DEFAULT export; both are additive named exports. Spread the snippet scope through to
  // each (GroupBar gets { grouping, groupableColumns, applyGrouping, clearGrouping };
  // DetailPanel gets { row }). Use them as-is, or fork either as a starter.
  let rows = $state([
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ]);
  let grouping = $state<string[]>([]);
  let expanded = $state<Record<string, boolean>>({});
</script>

<DataTable data={rows} groupable expandable bind:grouping bind:expanded>
  <Column field="region" header="Region" />
  <Column field="category" header="Category" />
  <Column field="units" header="Units" aggregationFn="sum" />

  <!-- GroupBar fills #groupBar; DetailPanel fills #detail. -->
  {#snippet groupBar(scope)}
    <GroupBar {...scope} />
  {/snippet}
  {#snippet detail(scope)}
    <DetailPanel {...scope} />
  {/snippet}
</DataTable>
ts
import { Component } from '@angular/core';
import {
  DataTable, Column, GroupBar, DetailPanel,
} from '@rozie-ui/data-table-angular';

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [DataTable, Column, GroupBar, DetailPanel],
  template: `
    <!-- OPT-IN drop-ins fill the #groupBar + #detail templates — DataTable stays the
         headless default; both are additive named exports. GroupBar takes the group-bar
         scope as inputs ([grouping] [groupableColumns] [applyGrouping] [clearGrouping]);
         DetailPanel takes [row]. Use them as-is, or fork either as a starter. -->
    <DataTable
      [data]="rows"
      [groupable]="true"
      [expandable]="true"
      [(grouping)]="grouping"
      [(expanded)]="expanded"
    >
      <Column field="region" header="Region" />
      <Column field="category" header="Category" />
      <Column field="units" header="Units" aggregationFn="sum" />

      <ng-template #groupBar let-grouping="grouping" let-groupableColumns="groupableColumns" let-applyGrouping="applyGrouping" let-clearGrouping="clearGrouping">
        <rozie-group-bar [grouping]="grouping" [groupableColumns]="groupableColumns" [applyGrouping]="applyGrouping" [clearGrouping]="clearGrouping" />
      </ng-template>
      <ng-template #detail let-row="row">
        <rozie-detail-panel [row]="row" />
      </ng-template>
    </DataTable>
  `,
})
export class DemoComponent {
  rows = [
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ];
  grouping: string[] = [];
  expanded: Record<string, boolean> = {};
}
tsx
import { createSignal } from 'solid-js';
import {
  DataTable, Column, GroupBar, DetailPanel,
} from '@rozie-ui/data-table-solid';

export function Demo() {
  // OPT-IN drop-ins fill the #groupBar + #detail slots — DataTable stays the headless
  // default; both are additive named exports. Spread the slot scope through to each
  // (GroupBar gets { grouping, groupableColumns, applyGrouping, clearGrouping };
  // DetailPanel gets { row }). Use them as-is, or fork either as a starter.
  const rows = [
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ];
  const [grouping, setGrouping] = createSignal<string[]>([]);
  const [expanded, setExpanded] = createSignal<Record<string, boolean>>({});
  return (
    <DataTable
      data={rows}
      groupable
      expandable
      grouping={grouping()}
      onGroupChange={setGrouping}
      expanded={expanded()}
      onExpandChange={setExpanded}
      // The #groupBar / #detail scoped slots are render props on Solid (the documented edge).
      groupBarSlot={(scope) => <GroupBar {...scope} />}
      detailSlot={(scope) => <DetailPanel {...scope} />}
    >
      <Column field="region" header="Region" />
      <Column field="category" header="Category" />
      <Column field="units" header="Units" aggregationFn="sum" />
    </DataTable>
  );
}
ts
import { html, render } from 'lit';
// The single side-effect import registers <rozie-data-table> AND the drop-in
// <rozie-group-bar> + <rozie-detail-panel> custom elements. DataTable stays the headless
// default; both are additive.
import '@rozie-ui/data-table-lit';

const rows = [
    { id: 1, region: 'North', category: 'Hardware', units: 3, score: 41 },
    { id: 2, region: 'North', category: 'Hardware', units: 5, score: 67 },
    { id: 3, region: 'North', category: 'Software', units: 2, score: 90 },
    { id: 4, region: 'South', category: 'Hardware', units: 7, score: 60 },
  ];

// #groupBar is the `.groupBar` property; #detail is the `.detail` property — each a
// function returning a Lit template, dispatched at render time. Pass the slot scope
// through as element properties (GroupBar: the group-bar scope; DetailPanel: { row }).
render(html`
  <rozie-data-table
    .data=${rows}
    groupable
    expandable
    .groupBar=${({ grouping, groupableColumns, applyGrouping, clearGrouping }) =>
      html`<rozie-group-bar .grouping=${grouping} .groupableColumns=${groupableColumns} .applyGrouping=${applyGrouping} .clearGrouping=${clearGrouping}></rozie-group-bar>`}
    .detail=${({ row }) => html`<rozie-detail-panel .row=${row}></rozie-detail-panel>`}
  >
    <rozie-column field="region" header="Region"></rozie-column>
    <rozie-column field="category" header="Category"></rozie-column>
    <rozie-column field="units" header="Units" .aggregationFn=${'sum'}></rozie-column>
  </rozie-data-table>
`, document.body);

Imperative handle

Beyond props and events, DataTable exposes imperative methods (declared once in the .rozie source via $expose). Grab a handle through your framework's native ref mechanism and call them directly:

tsx
import { useRef } from 'react';
import { DataTable, type DataTableHandle } from '@rozie-ui/data-table-react';

const tbl = useRef<DataTableHandle>(null);
// <DataTable ref={tbl} ... />
tbl.current?.toggleAllRows(true);
const selected = tbl.current?.getSelectedRows();
tbl.current?.editRow(0);                       // full-row edit on row 0
const range = tbl.current?.getSelectedRange(); // the active cell-range rectangle
tbl.current?.expandAll();                      // open every expandable row (collapseAll / toggleRowExpanded / getExpandedRows)
tbl.current?.applyGrouping(['region']);        // set grouping (clearGrouping to reset)
const cats = tbl.current?.getFacetedUniqueValues('category'); // cross-filtered facet keys (getFacetedMinMaxValues too)
vue
<script setup>
import { ref } from 'vue';
const tbl = ref();          // template ref
</script>

<template>
  <DataTable ref="tbl" :data="rows" />
  <button @click="tbl.clearSelection()">Clear</button>
  <button @click="tbl.editRow(0)">Edit row 0</button>
  <button @click="console.log(tbl.getSelectedRange())">Read range</button>
  <button @click="tbl.expandAll()">Expand all</button>
  <button @click="tbl.applyGrouping(['region'])">Group by region</button>
  <button @click="console.log(tbl.getFacetedUniqueValues('category'))">Facet keys</button>
</template>
svelte
<script>
  let tbl;                  // component instance via bind:this
</script>

<DataTable bind:this={tbl} data={rows} />
<button onclick={() => tbl.clearSelection()}>Clear</button>
<button onclick={() => tbl.editRow(0)}>Edit row 0</button>
<button onclick={() => console.log(tbl.getSelectedRange())}>Read range</button>
<button onclick={() => tbl.expandAll()}>Expand all</button>
<button onclick={() => tbl.applyGrouping(['region'])}>Group by region</button>
<button onclick={() => console.log(tbl.getFacetedUniqueValues('category'))}>Facet keys</button>
ts
@Component({ /* ... */ })
export class DemoComponent {
  @ViewChild(DataTable) tbl!: DataTable;   // or the viewChild() signal
  selectAll() { this.tbl.toggleAllRows(true); }
  read() { return this.tbl.getSelectedRows(); }
  editFirstRow() { this.tbl.editRow(0); }            // full-row edit on row 0
  readRange() { return this.tbl.getSelectedRange(); } // the active cell-range rectangle
  expandEverything() { this.tbl.expandAll(); }       // collapseAll / toggleRowExpanded / getExpandedRows
  groupByRegion() { this.tbl.applyGrouping(['region']); } // clearGrouping to reset
  facetKeys() { return this.tbl.getFacetedUniqueValues('category'); } // getFacetedMinMaxValues too
}
tsx
import { DataTable, type DataTableHandle } from '@rozie-ui/data-table-solid';

let handle: DataTableHandle | undefined;
// The ref callback receives the HANDLE object (not the DOM node).
<DataTable ref={(h) => (handle = h)} data={rows} />;
handle?.toggleAllRows(true);
handle?.editRow(0);                       // full-row edit on row 0
const range = handle?.getSelectedRange(); // the active cell-range rectangle
handle?.expandAll();                      // collapseAll / toggleRowExpanded / getExpandedRows
handle?.applyGrouping(['region']);        // clearGrouping to reset
const cats = handle?.getFacetedUniqueValues('category'); // getFacetedMinMaxValues too
ts
// The custom element IS the handle — exposed methods are public element
// methods.
const el = document.querySelector('rozie-data-table');
el.toggleAllRows(true);
const selected = el.getSelectedRows();
el.editRow(0);                       // full-row edit on row 0
const range = el.getSelectedRange();  // the active cell-range rectangle
el.expandAll();                      // collapseAll / toggleRowExpanded / getExpandedRows
el.applyGrouping(['region']);        // clearGrouping to reset
const cats = el.getFacetedUniqueValues('category'); // getFacetedMinMaxValues too

See also

Pre-v1.0 — internal monorepo.