Data Table
A fully data-driven table — sorting, selection, pagination, filtering, pinning, expansion and more, in React (@bpdm/ui) and Angular (@bpdm/ng).
The Data Table is fully data-driven: describe your columns once and pass an
array of data — no per-cell markup. Density, striping, sorting, selection,
pagination, filtering, column pinning, row expansion, a column toggle, global
search, reordering and virtualization are all opt-in props. In Angular it's
<bpdm-data-table> with the same columns / data inputs; custom cells use an
<ng-template> instead of React's inline cell.
Usage
Each column has an id, a header, and an accessor (plain value) or cell
(custom node). Pass a rowKey so selection/expansion survive sorting.
Member | Role | Team | Status | Tasks |
|---|---|---|---|---|
Milo Lindberg milo@bpdm.dev | Owner | Engineering | active | 128 |
Leo Martins leo@bpdm.dev | Admin | Design | invited | 0 |
Sara Kovac sara@bpdm.dev | Editor | Engineering | active | 86 |
Noah Bauer noah@bpdm.dev | Viewer | Support | disabled | 12 |
Ava Nguyen ava@bpdm.dev | Editor | Marketing | active | 54 |
Ivan Petrov ivan@bpdm.dev | Admin | Engineering | active | 203 |
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
import { Avatar } from '@bpdm/ui/avatar';
type Member = {
id: string;
name: string;
email: string;
role: string;
team: string;
status: 'active' | 'invited' | 'disabled';
tasks: number;
};
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', email: 'milo@bpdm.dev', role: 'Owner', team: 'Engineering', status: 'active', tasks: 128 },
{ id: 'm2', name: 'Leo Martins', email: 'leo@bpdm.dev', role: 'Admin', team: 'Design', status: 'invited', tasks: 0 },
{ id: 'm3', name: 'Sara Kovac', email: 'sara@bpdm.dev', role: 'Editor', team: 'Engineering', status: 'active', tasks: 86 },
];
// a rich identity cell — Avatar derives initials + colour from the name
function MemberCell({ member }: { member: Member }) {
return (
<div className="flex items-center gap-3">
<Avatar name={member.name} size="sm" />
<div className="leading-tight">
<div className="font-medium">{member.name}</div>
<div className="text-xs text-muted-foreground">{member.email}</div>
</div>
</div>
);
}
const columns: DataTableColumn<Member>[] = [
{ id: 'member', header: 'Member', cell: (r) => <MemberCell member={r} /> },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'team', header: 'Team', accessor: (r) => r.team },
{ id: 'status', header: 'Status', align: 'center', accessor: (r) => r.status },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function MembersTable() {
return <DataTable columns={columns} data={members} rowKey={(r) => r.id} />;
}accessor columns render as text; for a custom cell use an <ng-template> and
point the column's cell at it (see the example below).
import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmDataTable, BpdmAvatar, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; email: string; role: string; team: string; status: string; tasks: number };
@Component({
selector: 'members-table',
imports: [BpdmDataTable, BpdmAvatar],
templateUrl: './members-table.html',
})
export class MembersTable {
readonly memberCell = viewChild.required<TemplateRef<{ $implicit: Member }>>('memberCell');
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', email: 'milo@bpdm.dev', role: 'Owner', team: 'Engineering', status: 'active', tasks: 128 },
{ id: 'm2', name: 'Leo Martins', email: 'leo@bpdm.dev', role: 'Admin', team: 'Design', status: 'invited', tasks: 0 },
{ id: 'm3', name: 'Sara Kovac', email: 'sara@bpdm.dev', role: 'Editor', team: 'Engineering', status: 'active', tasks: 86 },
];
readonly rowKey = (r: Member) => r.id;
readonly columns = computed<DataTableColumn<Member>[]>(() => [
{ id: 'member', header: 'Member', cell: this.memberCell() },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'team', header: 'Team', accessor: (r) => r.team },
{ id: 'status', header: 'Status', align: 'center', accessor: (r) => r.status },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
]);
}<bpdm-data-table [columns]="columns()" [data]="members" [rowKey]="rowKey" />
<ng-template #memberCell let-row>
<div class="flex items-center gap-3">
<bpdm-avatar [name]="row.name" size="sm" />
<div class="leading-tight">
<div class="font-medium">{{ row.name }}</div>
<div class="text-xs text-muted-foreground">{{ row.email }}</div>
</div>
</div>
</ng-template>Sizes
Set the size prop for cell density — sm, md (default) or lg. The preview
switches between them with our own Tabs — a neat way to
compare densities, and a small example of composing two components.
Name | Email | Role | Team | Status | Tasks |
|---|---|---|---|---|---|
Milo Lindberg | milo@bpdm.dev | Owner | Engineering | active | 128 |
Leo Martins | leo@bpdm.dev | Admin | Design | invited | 0 |
Sara Kovac | sara@bpdm.dev | Editor | Engineering | active | 86 |
Noah Bauer | noah@bpdm.dev | Viewer | Support | disabled | 12 |
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
import { Tabs } from '@bpdm/ui/tabs';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function SizedTables() {
return (
<Tabs
defaultValue="md"
items={[
{ value: 'sm', label: 'Small', content: <DataTable columns={columns} data={members} rowKey={(r) => r.id} size="sm" /> },
{ value: 'md', label: 'Medium', content: <DataTable columns={columns} data={members} rowKey={(r) => r.id} size="md" /> },
{ value: 'lg', label: 'Large', content: <DataTable columns={columns} data={members} rowKey={(r) => r.id} size="lg" /> },
]}
/>
);
}import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmDataTable, BpdmTabs, type DataTableColumn, type TabItem } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'sized-tables',
imports: [BpdmDataTable, BpdmTabs],
templateUrl: './sized-tables.html',
})
export class SizedTables {
readonly sm = viewChild.required<TemplateRef<unknown>>('sm');
readonly md = viewChild.required<TemplateRef<unknown>>('md');
readonly lg = viewChild.required<TemplateRef<unknown>>('lg');
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
readonly tabs = computed<TabItem[]>(() => [
{ value: 'sm', label: 'Small', content: this.sm() },
{ value: 'md', label: 'Medium', content: this.md() },
{ value: 'lg', label: 'Large', content: this.lg() },
]);
}<bpdm-tabs [items]="tabs()" defaultValue="md" />
<ng-template #sm><bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" size="sm" /></ng-template>
<ng-template #md><bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" size="md" /></ng-template>
<ng-template #lg><bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" size="lg" /></ng-template>Borders & striping
The table is borderless by default — just a clean header rule and soft row
dividers, so it sits naturally on any page. Add striped for zebra rows, or opt
into an outlined card with frame (outer border) and bordered (column gridlines).
The preview toggles the three looks with our Tabs.
Name | Email | Role | Team | Status | Tasks |
|---|---|---|---|---|---|
Milo Lindberg | milo@bpdm.dev | Owner | Engineering | active | 128 |
Leo Martins | leo@bpdm.dev | Admin | Design | invited | 0 |
Sara Kovac | sara@bpdm.dev | Editor | Engineering | active | 86 |
Noah Bauer | noah@bpdm.dev | Viewer | Support | disabled | 12 |
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
import { Tabs } from '@bpdm/ui/tabs';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function BorderTables() {
return (
<Tabs
defaultValue="borderless"
items={[
{ value: 'borderless', label: 'Borderless', content: <DataTable columns={columns} data={members} rowKey={(r) => r.id} /> },
{ value: 'striped', label: 'Striped', content: <DataTable columns={columns} data={members} rowKey={(r) => r.id} striped /> },
{ value: 'outlined', label: 'Outlined', content: <DataTable columns={columns} data={members} rowKey={(r) => r.id} frame bordered /> },
]}
/>
);
}import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmDataTable, BpdmTabs, type DataTableColumn, type TabItem } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'border-tables',
imports: [BpdmDataTable, BpdmTabs],
templateUrl: './border-tables.html',
})
export class BorderTables {
readonly borderless = viewChild.required<TemplateRef<unknown>>('borderless');
readonly striped = viewChild.required<TemplateRef<unknown>>('striped');
readonly outlined = viewChild.required<TemplateRef<unknown>>('outlined');
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
readonly tabs = computed<TabItem[]>(() => [
{ value: 'borderless', label: 'Borderless', content: this.borderless() },
{ value: 'striped', label: 'Striped', content: this.striped() },
{ value: 'outlined', label: 'Outlined', content: this.outlined() },
]);
}<bpdm-tabs [items]="tabs()" defaultValue="borderless" />
<ng-template #borderless><bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" /></ng-template>
<ng-template #striped><bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" striped /></ng-template>
<ng-template #outlined><bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" frame bordered /></ng-template>Sorting
Mark a column sortable; clicking its header cycles asc → desc → none, with a live
arrow indicator. Add multiSort to sort by several columns at once — Shift+click
a header to append it (each shows its order); in Multi sort rows are grouped by
role, then by tasks. By default sorting is client-side — leave sort unset
and the table orders the data in memory. For server-side ordering, control sort +
onSortChange: the table renders exactly the order you give and fires onSortChange
so you can re-fetch the sorted page (it won't re-sort itself). The preview toggles all
three with our Tabs.
Email | Team | Status | |||
|---|---|---|---|---|---|
Ivan Petrov | ivan@bpdm.dev | Admin | Engineering | active | 203 |
Milo Lindberg | milo@bpdm.dev | Owner | Engineering | active | 128 |
Sara Kovac | sara@bpdm.dev | Editor | Engineering | active | 86 |
Ava Nguyen | ava@bpdm.dev | Editor | Marketing | active | 54 |
Noah Bauer | noah@bpdm.dev | Viewer | Support | disabled | 12 |
Leo Martins | leo@bpdm.dev | Admin | Design | invited | 0 |
import { useState } from 'react';
import { DataTable, type DataTableColumn, type DataTableSort } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Editor', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', sortable: true, accessor: (r) => r.name },
{ id: 'role', header: 'Role', sortable: true, accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, sortable: true, accessor: (r) => r.tasks },
];
// Single-column sort — clicking a header cycles asc → desc → none
export function SortableTable() {
return (
<DataTable
columns={columns}
data={members}
rowKey={(r) => r.id}
defaultSort={[{ id: 'tasks', dir: 'desc' }]}
onSortChange={(sort: DataTableSort[]) => console.log(sort)}
/>
);
}
// Multi-column sort — Shift+click a header to append it (each shows its order);
// here rows are grouped by role, then by tasks within each role
export function MultiSortTable() {
return (
<DataTable
columns={columns}
data={members}
rowKey={(r) => r.id}
multiSort
defaultSort={[
{ id: 'role', dir: 'asc' },
{ id: 'tasks', dir: 'desc' },
]}
/>
);
}
// Server-side sort — control `sort`; the table renders your order and calls
// onSortChange so you can re-fetch. It never re-sorts the data itself.
export function ServerSortedTable() {
const [sort, setSort] = useState<DataTableSort[]>([{ id: 'name', dir: 'asc' }]);
const rows = useSortedMembers(sort); // your data hook — fetches rows in `sort` order
return (
<DataTable columns={columns} data={rows} rowKey={(r) => r.id} sort={sort} onSortChange={setSort} />
);
}import { Component, signal } from '@angular/core';
import { BpdmDataTable, type DataTableColumn, type DataTableSort } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'sortable-table',
imports: [BpdmDataTable],
template: `
<!-- Single-column sort (client-side; leave sort uncontrolled) -->
<bpdm-data-table
[columns]="columns" [data]="members" [rowKey]="rowKey"
[defaultSort]="[{ id: 'tasks', dir: 'desc' }]" />
<!-- Multi-column sort — Shift+click a header to append (grouped by role, then tasks) -->
<bpdm-data-table
[columns]="columns" [data]="members" [rowKey]="rowKey"
multiSort
[defaultSort]="[{ id: 'role', dir: 'asc' }, { id: 'tasks', dir: 'desc' }]" />
<!-- Server-side sort — control [sort]; re-fetch on (sortChange), table won't re-sort -->
<bpdm-data-table
[columns]="columns" [data]="serverRows()" [rowKey]="rowKey"
[sort]="sort()" (sortChange)="onSort($event)" />
`,
})
export class SortableTable {
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Editor', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', sortable: true, accessor: (r) => r.name },
{ id: 'role', header: 'Role', sortable: true, accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, sortable: true, accessor: (r) => r.tasks },
];
// server-side: sort state + the rows your backend returns in that order
readonly sort = signal<DataTableSort[]>([{ id: 'name', dir: 'asc' }]);
readonly serverRows = signal<Member[]>([]);
onSort(next: DataTableSort[]) {
this.sort.set(next);
// re-fetch: this.api.members(next).subscribe(rows => this.serverRows.set(rows));
}
}Column filters
Set filterable on a column for a header filter menu — filterType is text,
number, or select (pick from the column's distinct values). Filtering is
client-side by default (the table filters data in memory). For server-side
filtering, control filters + onFiltersChange (and searchValue + onSearchChange
for search): the same funnel UI is shown, but the table stops filtering itself and
just emits the change so you can re-fetch — exactly like controlled sorting. The
preview toggles Client and Server with our Tabs.
Name | Email | Role | Team | Status | Tasks |
|---|---|---|---|---|---|
Milo Lindberg | milo@bpdm.dev | Owner | Engineering | active | 128 |
Leo Martins | leo@bpdm.dev | Admin | Design | invited | 0 |
Sara Kovac | sara@bpdm.dev | Editor | Engineering | active | 86 |
Noah Bauer | noah@bpdm.dev | Viewer | Support | disabled | 12 |
Ava Nguyen | ava@bpdm.dev | Editor | Marketing | active | 54 |
Ivan Petrov | ivan@bpdm.dev | Admin | Engineering | active | 203 |
Client — mark columns filterable; the table filters data in memory:
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', filterable: true, accessor: (r) => r.name },
{ id: 'role', header: 'Role', filterable: true, filterType: 'select', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, filterable: true, accessor: (r) => r.tasks },
];
export function FilterableTable() {
return <DataTable columns={columns} data={members} rowKey={(r) => r.id} />;
}Server — control filters; the same funnel UI shows, but you fetch the filtered
rows (swap filterMembers for a real API call). A ColumnFilter is
{ matchMode: 'all' | 'any', rules: [{ op, value }] }:
import { useMemo, useState } from 'react';
import { DataTable, type DataTableColumn, type ColumnFilter } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const ALL: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', filterable: true, accessor: (r) => r.name },
{ id: 'role', header: 'Role', filterable: true, filterType: 'select', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, filterable: true, accessor: (r) => r.tasks },
];
const FIELD: Record<string, (m: Member) => string | number> = {
name: (m) => m.name,
role: (m) => m.role,
tasks: (m) => m.tasks,
};
// stand-in for your backend — apply the active filters (replace with a fetch)
function filterMembers(filters: Record<string, ColumnFilter>): Member[] {
const active = Object.entries(filters).filter(([, f]) => f.rules.some((r) => r.value !== ''));
if (!active.length) return ALL;
return ALL.filter((m) =>
active.every(([id, f]) => {
const raw = FIELD[id]?.(m);
const res = f.rules
.filter((r) => r.value !== '')
.map((r) => {
const cell = String(raw ?? '').toLowerCase();
const v = r.value.toLowerCase();
switch (r.op) {
case 'equals': return cell === v;
case 'notEquals': return cell !== v;
case 'startsWith': return cell.startsWith(v);
case 'endsWith': return cell.endsWith(v);
case 'gt': return Number(raw) > Number(r.value);
case 'gte': return Number(raw) >= Number(r.value);
case 'lt': return Number(raw) < Number(r.value);
case 'lte': return Number(raw) <= Number(r.value);
default: return cell.includes(v);
}
});
return f.matchMode === 'all' ? res.every(Boolean) : res.some(Boolean);
}),
);
}
export function ServerFilteredTable() {
const [filters, setFilters] = useState<Record<string, ColumnFilter>>({});
const rows = useMemo(() => filterMembers(filters), [filters]);
return (
<DataTable columns={columns} data={rows} rowKey={(r) => r.id} filters={filters} onFiltersChange={setFilters} />
);
}Client — mark columns filterable; the table filters in memory:
import { Component } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'filterable-table',
imports: [BpdmDataTable],
template: `<bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" />`,
})
export class FilterableTable {
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', filterable: true, accessor: (r) => r.name },
{ id: 'role', header: 'Role', filterable: true, filterType: 'select', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, filterable: true, accessor: (r) => r.tasks },
];
}Server — control [filters]; re-fetch on (filtersChange):
import { Component, signal, computed } from '@angular/core';
import { BpdmDataTable, type DataTableColumn, type ColumnFilter } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
const ALL: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const FIELD: Record<string, (m: Member) => string | number> = {
name: (m) => m.name,
role: (m) => m.role,
tasks: (m) => m.tasks,
};
@Component({
selector: 'server-filtered-table',
imports: [BpdmDataTable],
template: `
<bpdm-data-table
[columns]="columns" [data]="rows()" [rowKey]="rowKey"
[filters]="filters()" (filtersChange)="onFilters($event)" />
`,
})
export class ServerFilteredTable {
readonly rowKey = (r: Member) => r.id;
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', filterable: true, accessor: (r) => r.name },
{ id: 'role', header: 'Role', filterable: true, filterType: 'select', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, filterable: true, accessor: (r) => r.tasks },
];
readonly filters = signal<Record<string, ColumnFilter>>({});
onFilters(next: Record<string, ColumnFilter>) {
this.filters.set(next);
}
// stand-in for your backend — apply the active filters (replace with a fetch)
readonly rows = computed(() => {
const active = Object.entries(this.filters()).filter(([, f]) => f.rules.some((r) => r.value !== ''));
if (!active.length) return ALL;
return ALL.filter((m) =>
active.every(([id, f]) => {
const raw = FIELD[id]?.(m);
const res = f.rules
.filter((r) => r.value !== '')
.map((r) => {
const cell = String(raw ?? '').toLowerCase();
const v = r.value.toLowerCase();
switch (r.op) {
case 'equals': return cell === v;
case 'notEquals': return cell !== v;
case 'startsWith': return cell.startsWith(v);
case 'endsWith': return cell.endsWith(v);
case 'gt': return Number(raw) > Number(r.value);
case 'gte': return Number(raw) >= Number(r.value);
case 'lt': return Number(raw) < Number(r.value);
case 'lte': return Number(raw) <= Number(r.value);
default: return cell.includes(v);
}
});
return f.matchMode === 'all' ? res.every(Boolean) : res.some(Boolean);
}),
);
});
}Search
searchable adds a global search box that filters rows across all columns —
client-side by default. For server-side search, control searchValue +
onSearchChange: the box is shown but the table stops filtering itself and emits
the query so you can re-fetch. The preview toggles Client and Server with
our Tabs.
Member | Location | Role | Team | Status |
|---|---|---|---|---|
Milo Lindberg | 🇸🇪Sweden | Owner | Engineering | active |
Leo Martins | 🇧🇷Brazil | Admin | Design | invited |
Sara Kovac | 🇭🇷Croatia | Editor | Engineering | active |
Noah Bauer | 🇩🇪Germany | Viewer | Support | disabled |
Ava Nguyen | 🇻🇳Vietnam | Editor | Marketing | active |
Ivan Petrov | 🇧🇬Bulgaria | Admin | Engineering | active |
Client — hand the table everything; it searches data in memory:
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function SearchableTable() {
return (
<DataTable columns={columns} data={members} rowKey={(r) => r.id} searchable searchPlaceholder="Search members…" />
);
}Server — control searchValue; the box is shown but you fetch the results
(swap searchMembers for a debounced API call):
import { useMemo, useState } from 'react';
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const ALL: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
// stand-in for your backend — replace with a (debounced) fetch
function searchMembers(q: string): Member[] {
const query = q.trim().toLowerCase();
if (!query) return ALL;
return ALL.filter((m) => [m.name, m.role].some((v) => v.toLowerCase().includes(query)));
}
export function ServerSearchTable() {
const [q, setQ] = useState('');
const rows = useMemo(() => searchMembers(q), [q]);
return (
<DataTable
columns={columns}
data={rows}
rowKey={(r) => r.id}
searchable
searchPlaceholder="Search members…"
searchValue={q}
onSearchChange={setQ}
/>
);
}Client — the table searches data in memory:
import { Component } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'searchable-table',
imports: [BpdmDataTable],
template: `<bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" searchable searchPlaceholder="Search members…" />`,
})
export class SearchableTable {
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
}Server — control [searchValue]; re-fetch on (searchChange) (debounced):
import { Component, signal, computed } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
const ALL: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
@Component({
selector: 'server-search-table',
imports: [BpdmDataTable],
template: `
<bpdm-data-table
[columns]="columns" [data]="rows()" [rowKey]="rowKey"
searchable searchPlaceholder="Search members…"
[searchValue]="q()" (searchChange)="onSearch($event)" />
`,
})
export class ServerSearchTable {
readonly rowKey = (r: Member) => r.id;
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
readonly q = signal('');
// stand-in for your backend — replace with a (debounced) API call
readonly rows = computed(() => {
const query = this.q().trim().toLowerCase();
if (!query) return ALL;
return ALL.filter((m) => [m.name, m.role].some((v) => v.toLowerCase().includes(query)));
});
onSearch(value: string) {
this.q.set(value);
}
}Pagination
The pagination prop supports three modes — the preview toggles between them with
our Tabs:
- Client — the table slices
dataitself (just give it everything). - Server (offset) — you fetch the current page; supply
total+ handleonPageChange. - Cursor — prev/next only (no page numbers), for cursor-based APIs.
Member | Location | Role | Seats | Status |
|---|---|---|---|---|
Milo 1 | 🇸🇪Sweden | Owner | 24 | active |
Leo 2 | 🇧🇷Brazil | Admin | 8 | invited |
Sara 3 | 🇭🇷Croatia | Editor | 16 | active |
Noah 4 | 🇩🇪Germany | Viewer | 4 | disabled |
Ava 5 | 🇻🇳Vietnam | Editor | 12 | active |
Ivan 6 | 🇧🇬Bulgaria | Admin | 40 | active |
Milo 7 | 🇸🇪Sweden | Owner | 24 | active |
Leo 8 | 🇧🇷Brazil | Admin | 8 | invited |
import { useState } from 'react';
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Row = { id: string; name: string; role: string };
const columns: DataTableColumn<Row>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
];
// 1 — Client: hand the table everything; it pages internally.
export function ClientPaged({ rows }: { rows: Row[] }) {
return (
<DataTable
columns={columns}
data={rows}
rowKey={(r) => r.id}
pagination={{ pageSize: 8, pageSizeOptions: [8, 16, 32] }}
/>
);
}
// 2 — Server (offset): fetch the current page; supply `total` + handle `onPageChange`.
export function ServerPaged() {
const [page, setPage] = useState(1);
const pageSize = 8;
const { rows, total } = useMembersPage(page, pageSize); // your data hook
return (
<DataTable
columns={columns}
data={rows}
rowKey={(r) => r.id}
pagination={{ mode: 'server', page, pageSize, total, onPageChange: setPage }}
/>
);
}
// 3 — Cursor: prev/next only — for cursor-based APIs (no page numbers).
export function CursorPaged() {
const { rows, hasNextPage, hasPreviousPage, next, prev, rangeLabel } = useCursor();
return (
<DataTable
columns={columns}
data={rows}
rowKey={(r) => r.id}
pagination={{
mode: 'cursor',
hasNextPage,
hasPreviousPage,
onNextPage: next,
onPreviousPage: prev,
rangeLabel,
}}
/>
);
}import { Component, computed, signal } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Row = { id: string; name: string; role: string };
@Component({
selector: 'paged-table',
imports: [BpdmDataTable],
template: `
<!-- 1 — Client: the table pages internally -->
<bpdm-data-table [columns]="columns" [data]="rows()" [rowKey]="rowKey" [pagination]="clientPg" />
<!-- 2 — Server (offset): bind a computed config; you fetch each page -->
<bpdm-data-table [columns]="columns" [data]="pageRows()" [rowKey]="rowKey" [pagination]="serverPg()" />
<!-- 3 — Cursor: prev/next only -->
<bpdm-data-table [columns]="columns" [data]="pageRows()" [rowKey]="rowKey" [pagination]="cursorPg()" />
`,
})
export class PagedTable {
readonly rowKey = (r: Row) => r.id;
readonly columns: DataTableColumn<Row>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
];
// client
readonly clientPg = { pageSize: 8, pageSizeOptions: [8, 16, 32] };
// server (offset) — `page` drives your data fetch; `total` comes from the backend
readonly page = signal(1);
readonly total = signal(0);
readonly serverPg = computed(() => ({
mode: 'server' as const,
page: this.page(),
pageSize: 8,
total: this.total(),
onPageChange: (p: number) => this.page.set(p),
}));
// cursor — prev/next only
readonly hasNext = signal(true);
readonly hasPrev = signal(false);
readonly cursorPg = computed(() => ({
mode: 'cursor' as const,
hasNextPage: this.hasNext(),
hasPreviousPage: this.hasPrev(),
onNextPage: () => this.loadNext(),
onPreviousPage: () => this.loadPrev(),
}));
// your data layer fills these
rows = signal<Row[]>([]);
pageRows = signal<Row[]>([]);
loadNext() {}
loadPrev() {}
}Selection
selectable adds a selection column — checkboxes with a select-all by default,
or radios with selectionMode="single". The preview toggles the two modes with
our Tabs. Drive it with selectedKeys + onSelectionChange to build a selection
toolbar (bulk actions, a "clear" button, …).
Member | Collaborators | Role | Status | Tasks | |
|---|---|---|---|---|---|
Milo Lindberg | +1 | Owner | active | 128 | |
Leo Martins | +1 | Admin | invited | 0 | |
Sara Kovac | +1 | Editor | active | 86 | |
Noah Bauer | +1 | Viewer | disabled | 12 | |
Ava Nguyen | +1 | Editor | active | 54 | |
Ivan Petrov | +1 | Admin | active | 203 |
import { useState, type Key } from 'react';
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function SelectableTable() {
const [keys, setKeys] = useState<Key[]>([]);
return (
<>
{keys.length > 0 && <p>{keys.length} selected</p>}
<DataTable
columns={columns}
data={members}
rowKey={(r) => r.id}
selectable
selectedKeys={keys}
onSelectionChange={(next) => setKeys(next)}
/>
</>
);
}import { Component, signal } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'selectable-table',
imports: [BpdmDataTable],
template: `
@if (keys().length) { <p>{{ keys().length }} selected</p> }
<bpdm-data-table
[columns]="columns"
[data]="members"
[rowKey]="rowKey"
selectable
[selectedKeys]="keys()"
(selectionChange)="keys.set($event.keys)"
/>
`,
})
export class SelectableTable {
readonly rowKey = (r: Member) => r.id;
readonly keys = signal<(string | number)[]>([]);
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
}Clickable rows
onRowClick makes rows interactive (pointer cursor + hover).
Member | Location | Role | Team | Status |
|---|---|---|---|---|
Milo Lindberg | 🇸🇪Sweden | Owner | Engineering | active |
Leo Martins | 🇧🇷Brazil | Admin | Design | invited |
Sara Kovac | 🇭🇷Croatia | Editor | Engineering | active |
Noah Bauer | 🇩🇪Germany | Viewer | Support | disabled |
Ava Nguyen | 🇻🇳Vietnam | Editor | Marketing | active |
Ivan Petrov | 🇧🇬Bulgaria | Admin | Engineering | active |
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function ClickableTable() {
return (
<DataTable columns={columns} data={members} rowKey={(r) => r.id} onRowClick={(row) => console.log(row.name)} />
);
}import { Component } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'clickable-table',
imports: [BpdmDataTable],
template: `<bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" [onRowClick]="open" />`,
})
export class ClickableTable {
readonly rowKey = (r: Member) => r.id;
readonly open = (row: Member) => console.log(row.name);
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
}Expandable rows
renderExpanded shows a detail panel under a row — it's any JSX, so compose your
own components. Here it's an Avatar, a couple of Badges and a ProgressBar.
expandMode="single" keeps only one open.
| Expand | Name | Email | Role | Team | Status | Tasks |
|---|---|---|---|---|---|---|
Milo Lindberg | milo@bpdm.dev | Owner | Engineering | active | 128 | |
Leo Martins | leo@bpdm.dev | Admin | Design | invited | 0 | |
Sara Kovac | sara@bpdm.dev | Editor | Engineering | active | 86 | |
Noah Bauer | noah@bpdm.dev | Viewer | Support | disabled | 12 | |
Ava Nguyen | ava@bpdm.dev | Editor | Marketing | active | 54 | |
Ivan Petrov | ivan@bpdm.dev | Admin | Engineering | active | 203 |
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
import { Avatar } from '@bpdm/ui/avatar';
import { Badge } from '@bpdm/ui/badge';
import { ProgressBar } from '@bpdm/ui/progress';
type Member = { id: string; name: string; email: string; role: string; team: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', email: 'milo@bpdm.dev', role: 'Owner', team: 'Engineering', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', email: 'sara@bpdm.dev', role: 'Editor', team: 'Engineering', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', email: 'noah@bpdm.dev', role: 'Viewer', team: 'Support', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function ExpandableTable() {
return (
<DataTable
columns={columns}
data={members}
rowKey={(r) => r.id}
expandMode="single"
renderExpanded={(r) => (
<div className="flex max-w-2xl flex-col gap-5 px-2 py-2 sm:flex-row sm:items-center sm:gap-8">
<div className="flex items-center gap-3">
<Avatar name={r.name} size="lg" />
<div className="leading-tight">
<div className="font-medium">{r.name}</div>
<div className="text-xs text-muted-foreground">{r.email}</div>
<div className="mt-2 flex gap-1.5">
<Badge variant="neutral" appearance="soft">{r.role}</Badge>
<Badge variant="secondary" appearance="soft">{r.team}</Badge>
</div>
</div>
</div>
<div className="w-full sm:w-56">
<ProgressBar
value={r.tasks}
max={240}
variant="primary"
showValue
label="Tasks completed"
format={(v, m) => `${v}/${m}`}
/>
</div>
</div>
)}
/>
);
}import { Component } from '@angular/core';
import { BpdmDataTable, BpdmAvatar, BpdmBadge, BpdmProgressBar, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; email: string; role: string; team: string; tasks: number };
@Component({
selector: 'expandable-table',
imports: [BpdmDataTable, BpdmAvatar, BpdmBadge, BpdmProgressBar],
template: `
<bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" expandMode="single" [expandedTemplate]="detail" />
<ng-template #detail let-row>
<div class="flex max-w-2xl flex-col gap-5 px-2 py-2 sm:flex-row sm:items-center sm:gap-8">
<div class="flex items-center gap-3">
<bpdm-avatar [name]="row.name" size="lg" />
<div class="leading-tight">
<div class="font-medium">{{ row.name }}</div>
<div class="text-xs text-muted-foreground">{{ row.email }}</div>
<div class="mt-2 flex gap-1.5">
<bpdm-badge variant="neutral" appearance="soft">{{ row.role }}</bpdm-badge>
<bpdm-badge variant="secondary" appearance="soft">{{ row.team }}</bpdm-badge>
</div>
</div>
</div>
<div class="w-full sm:w-56">
<bpdm-progress-bar [value]="row.tasks" [max]="240" variant="primary" showValue label="Tasks completed" />
</div>
</div>
</ng-template>
`,
})
export class ExpandableTable {
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', email: 'milo@bpdm.dev', role: 'Owner', team: 'Engineering', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', email: 'sara@bpdm.dev', role: 'Editor', team: 'Engineering', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', email: 'noah@bpdm.dev', role: 'Viewer', team: 'Support', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
}Editable cells
There's no separate "edit mode" prop — a cell is just a renderer, so an editable
cell is one that swaps its text for an input. Hold the rows in state and commit on
Enter or blur (Esc cancels). It composes our Input,
and works with sorting, selection and pagination unchanged.
Name | Email | Role | Team | Tasks |
|---|---|---|---|---|
| milo@bpdm.dev | Engineering | |||
| leo@bpdm.dev | Design | |||
| sara@bpdm.dev | Engineering | |||
| noah@bpdm.dev | Support | |||
| ava@bpdm.dev | Marketing | |||
| ivan@bpdm.dev | Engineering |
import { useState } from 'react';
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
import { Input } from '@bpdm/ui/input';
type Member = { id: string; name: string; role: string; tasks: number };
// click to edit; Enter or blur commits, Esc cancels
function EditableCell({
value,
onCommit,
numeric = false,
}: {
value: string | number;
onCommit: (next: string) => void;
numeric?: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(String(value));
if (editing) {
return (
<Input
size="sm"
autoFocus
type={numeric ? 'number' : 'text'}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => {
onCommit(draft);
setEditing(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
onCommit(draft);
setEditing(false);
} else if (e.key === 'Escape') {
setEditing(false);
}
}}
/>
);
}
return (
<button
type="button"
onClick={() => {
setDraft(String(value));
setEditing(true);
}}
className="w-full cursor-text rounded-md px-2 py-1 text-left hover:bg-muted"
>
{value}
</button>
);
}
export function EditableTable() {
const [rows, setRows] = useState<Member[]>([
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
]);
const patch = (id: string, field: keyof Member, next: string) =>
setRows((rs) =>
rs.map((r) => (r.id === id ? { ...r, [field]: field === 'tasks' ? Number(next) || 0 : next } : r)),
);
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', cell: (r) => <EditableCell value={r.name} onCommit={(v) => patch(r.id, 'name', v)} /> },
{ id: 'role', header: 'Role', cell: (r) => <EditableCell value={r.role} onCommit={(v) => patch(r.id, 'role', v)} /> },
{ id: 'tasks', header: 'Tasks', numeric: true, cell: (r) => <EditableCell value={r.tasks} numeric onCommit={(v) => patch(r.id, 'tasks', v)} /> },
];
return <DataTable columns={columns} data={rows} rowKey={(r) => r.id} />;
}The cell is an <ng-template> that shows a bpdmInput while editing, tracked by an
editing signal; commit() writes back to the rows signal.
import { Component, signal, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmDataTable, BpdmInput, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'editable-table',
imports: [BpdmDataTable, BpdmInput],
templateUrl: './data-table-editable.html',
})
export class EditableTable {
readonly nameCell = viewChild.required<TemplateRef<{ $implicit: Member }>>('nameCell');
readonly roleCell = viewChild.required<TemplateRef<{ $implicit: Member }>>('roleCell');
readonly tasksCell = viewChild.required<TemplateRef<{ $implicit: Member }>>('tasksCell');
readonly rowKey = (r: Member) => r.id;
readonly editing = signal<string | null>(null); // `${id}:${field}`
readonly rows = signal<Member[]>([
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
]);
edit(id: string, field: 'name' | 'role' | 'tasks') {
this.editing.set(`${id}:${field}`);
}
commit(row: Member, field: 'name' | 'role' | 'tasks', value: string) {
const next = field === 'tasks' ? Number(value) || 0 : value;
this.rows.update((rs) => rs.map((r) => (r.id === row.id ? { ...r, [field]: next } : r)));
this.editing.set(null);
}
readonly columns = computed<DataTableColumn<Member>[]>(() => [
{ id: 'name', header: 'Name', cell: this.nameCell() },
{ id: 'role', header: 'Role', cell: this.roleCell() },
{ id: 'tasks', header: 'Tasks', numeric: true, cell: this.tasksCell() },
]);
}<bpdm-data-table [columns]="columns()" [data]="rows()" [rowKey]="rowKey" />
<ng-template #nameCell let-row>
@if (editing() === row.id + ':name') {
<input bpdmInput size="sm" autofocus [value]="row.name"
(blur)="commit(row, 'name', $any($event.target).value)"
(keydown.enter)="commit(row, 'name', $any($event.target).value)"
(keydown.escape)="editing.set(null)" />
} @else {
<button type="button" class="w-full cursor-text rounded-md px-2 py-1 text-left hover:bg-muted"
(click)="edit(row.id, 'name')">{{ row.name }}</button>
}
</ng-template>
<ng-template #roleCell let-row>
@if (editing() === row.id + ':role') {
<input bpdmInput size="sm" autofocus [value]="row.role"
(blur)="commit(row, 'role', $any($event.target).value)"
(keydown.enter)="commit(row, 'role', $any($event.target).value)"
(keydown.escape)="editing.set(null)" />
} @else {
<button type="button" class="w-full cursor-text rounded-md px-2 py-1 text-left hover:bg-muted"
(click)="edit(row.id, 'role')">{{ row.role }}</button>
}
</ng-template>
<ng-template #tasksCell let-row>
@if (editing() === row.id + ':tasks') {
<input bpdmInput size="sm" type="number" autofocus class="text-right" [value]="row.tasks"
(blur)="commit(row, 'tasks', $any($event.target).value)"
(keydown.enter)="commit(row, 'tasks', $any($event.target).value)"
(keydown.escape)="editing.set(null)" />
} @else {
<button type="button" class="flex w-full cursor-text justify-end rounded-md px-2 py-1 tabular-nums hover:bg-muted"
(click)="edit(row.id, 'tasks')">{{ row.tasks }}</button>
}
</ng-template>Frozen & pinnable columns
Give a column pin: 'left' | 'right' (with a numeric width) to freeze it while
the table scrolls horizontally. Scroll the table sideways — Name stays pinned
on the left and Tasks on the right while the middle columns move. Add pinnable
for a per-column header menu to pin/unpin at runtime.
Name | Email | Role | Team | Location | Seats | Status | Tasks |
|---|---|---|---|---|---|---|---|
Milo Lindberg | milo@bpdm.dev | Owner | Engineering | 🇸🇪Sweden | 24 | active | 128 |
Leo Martins | leo@bpdm.dev | Admin | Design | 🇧🇷Brazil | 8 | invited | 0 |
Sara Kovac | sara@bpdm.dev | Editor | Engineering | 🇭🇷Croatia | 16 | active | 86 |
Noah Bauer | noah@bpdm.dev | Viewer | Support | 🇩🇪Germany | 4 | disabled | 12 |
Ava Nguyen | ava@bpdm.dev | Editor | Marketing | 🇻🇳Vietnam | 12 | active | 54 |
Ivan Petrov | ivan@bpdm.dev | Admin | Engineering | 🇧🇬Bulgaria | 40 | active | 203 |
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; email: string; role: string; team: string; country: string; seats: number; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', email: 'milo@bpdm.dev', role: 'Owner', team: 'Engineering', country: 'Sweden', seats: 24, tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', email: 'sara@bpdm.dev', role: 'Editor', team: 'Engineering', country: 'Croatia', seats: 16, tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', email: 'noah@bpdm.dev', role: 'Viewer', team: 'Support', country: 'Germany', seats: 4, tasks: 12 },
];
// wide columns force horizontal scroll → the pinned edges (left + right) stay put
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', pin: 'left', width: 200, accessor: (r) => r.name },
{ id: 'email', header: 'Email', width: 220, accessor: (r) => r.email },
{ id: 'role', header: 'Role', width: 140, accessor: (r) => r.role },
{ id: 'team', header: 'Team', width: 150, accessor: (r) => r.team },
{ id: 'country', header: 'Country', width: 160, accessor: (r) => r.country },
{ id: 'seats', header: 'Seats', width: 110, numeric: true, accessor: (r) => r.seats },
{ id: 'tasks', header: 'Tasks', pin: 'right', width: 120, numeric: true, accessor: (r) => r.tasks },
];
export function FrozenTable() {
return <DataTable columns={columns} data={members} rowKey={(r) => r.id} frame pinnable maxHeight={300} />;
}import { Component } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; email: string; role: string; team: string; country: string; seats: number; tasks: number };
@Component({
selector: 'frozen-table',
imports: [BpdmDataTable],
template: `<bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" frame pinnable [maxHeight]="300" />`,
})
export class FrozenTable {
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', email: 'milo@bpdm.dev', role: 'Owner', team: 'Engineering', country: 'Sweden', seats: 24, tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', email: 'sara@bpdm.dev', role: 'Editor', team: 'Engineering', country: 'Croatia', seats: 16, tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', email: 'noah@bpdm.dev', role: 'Viewer', team: 'Support', country: 'Germany', seats: 4, tasks: 12 },
];
// wide columns force horizontal scroll → the pinned edges (left + right) stay put
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', pin: 'left', width: 200, accessor: (r) => r.name },
{ id: 'email', header: 'Email', width: 220, accessor: (r) => r.email },
{ id: 'role', header: 'Role', width: 140, accessor: (r) => r.role },
{ id: 'team', header: 'Team', width: 150, accessor: (r) => r.team },
{ id: 'country', header: 'Country', width: 160, accessor: (r) => r.country },
{ id: 'seats', header: 'Seats', width: 110, numeric: true, accessor: (r) => r.seats },
{ id: 'tasks', header: 'Tasks', pin: 'right', width: 120, numeric: true, accessor: (r) => r.tasks },
];
}Sticky header & scroll
Cap the height with maxHeight and set stickyHeader — the body scrolls while
the header stays pinned.
Name | Email | Role | Team | Status | Tasks |
|---|---|---|---|---|---|
Milo 1 | milo@bpdm.dev | Owner | Engineering | active | 0 |
Leo 2 | leo@bpdm.dev | Admin | Design | invited | 7 |
Sara 3 | sara@bpdm.dev | Editor | Engineering | active | 14 |
Noah 4 | noah@bpdm.dev | Viewer | Support | disabled | 21 |
Ava 5 | ava@bpdm.dev | Editor | Marketing | active | 28 |
Ivan 6 | ivan@bpdm.dev | Admin | Engineering | active | 35 |
Milo 7 | milo@bpdm.dev | Owner | Engineering | active | 42 |
Leo 8 | leo@bpdm.dev | Admin | Design | invited | 49 |
Sara 9 | sara@bpdm.dev | Editor | Engineering | active | 56 |
Noah 10 | noah@bpdm.dev | Viewer | Support | disabled | 63 |
Ava 11 | ava@bpdm.dev | Editor | Marketing | active | 70 |
Ivan 12 | ivan@bpdm.dev | Admin | Engineering | active | 77 |
Milo 13 | milo@bpdm.dev | Owner | Engineering | active | 84 |
Leo 14 | leo@bpdm.dev | Admin | Design | invited | 91 |
Sara 15 | sara@bpdm.dev | Editor | Engineering | active | 98 |
Noah 16 | noah@bpdm.dev | Viewer | Support | disabled | 105 |
Ava 17 | ava@bpdm.dev | Editor | Marketing | active | 112 |
Ivan 18 | ivan@bpdm.dev | Admin | Engineering | active | 119 |
Milo 19 | milo@bpdm.dev | Owner | Engineering | active | 126 |
Leo 20 | leo@bpdm.dev | Admin | Design | invited | 133 |
Sara 21 | sara@bpdm.dev | Editor | Engineering | active | 140 |
Noah 22 | noah@bpdm.dev | Viewer | Support | disabled | 147 |
Ava 23 | ava@bpdm.dev | Editor | Marketing | active | 154 |
Ivan 24 | ivan@bpdm.dev | Admin | Engineering | active | 161 |
Milo 25 | milo@bpdm.dev | Owner | Engineering | active | 168 |
Leo 26 | leo@bpdm.dev | Admin | Design | invited | 175 |
Sara 27 | sara@bpdm.dev | Editor | Engineering | active | 182 |
Noah 28 | noah@bpdm.dev | Viewer | Support | disabled | 189 |
Ava 29 | ava@bpdm.dev | Editor | Marketing | active | 196 |
Ivan 30 | ivan@bpdm.dev | Admin | Engineering | active | 203 |
Milo 31 | milo@bpdm.dev | Owner | Engineering | active | 210 |
Leo 32 | leo@bpdm.dev | Admin | Design | invited | 217 |
Sara 33 | sara@bpdm.dev | Editor | Engineering | active | 224 |
Noah 34 | noah@bpdm.dev | Viewer | Support | disabled | 231 |
Ava 35 | ava@bpdm.dev | Editor | Marketing | active | 238 |
Ivan 36 | ivan@bpdm.dev | Admin | Engineering | active | 5 |
Milo 37 | milo@bpdm.dev | Owner | Engineering | active | 12 |
Leo 38 | leo@bpdm.dev | Admin | Design | invited | 19 |
Sara 39 | sara@bpdm.dev | Editor | Engineering | active | 26 |
Noah 40 | noah@bpdm.dev | Viewer | Support | disabled | 33 |
Ava 41 | ava@bpdm.dev | Editor | Marketing | active | 40 |
Ivan 42 | ivan@bpdm.dev | Admin | Engineering | active | 47 |
Milo 43 | milo@bpdm.dev | Owner | Engineering | active | 54 |
Leo 44 | leo@bpdm.dev | Admin | Design | invited | 61 |
Sara 45 | sara@bpdm.dev | Editor | Engineering | active | 68 |
Noah 46 | noah@bpdm.dev | Viewer | Support | disabled | 75 |
Ava 47 | ava@bpdm.dev | Editor | Marketing | active | 82 |
Ivan 48 | ivan@bpdm.dev | Admin | Engineering | active | 89 |
Milo 49 | milo@bpdm.dev | Owner | Engineering | active | 96 |
Leo 50 | leo@bpdm.dev | Admin | Design | invited | 103 |
Sara 51 | sara@bpdm.dev | Editor | Engineering | active | 110 |
Noah 52 | noah@bpdm.dev | Viewer | Support | disabled | 117 |
Ava 53 | ava@bpdm.dev | Editor | Marketing | active | 124 |
Ivan 54 | ivan@bpdm.dev | Admin | Engineering | active | 131 |
Milo 55 | milo@bpdm.dev | Owner | Engineering | active | 138 |
Leo 56 | leo@bpdm.dev | Admin | Design | invited | 145 |
Sara 57 | sara@bpdm.dev | Editor | Engineering | active | 152 |
Noah 58 | noah@bpdm.dev | Viewer | Support | disabled | 159 |
Ava 59 | ava@bpdm.dev | Editor | Marketing | active | 166 |
Ivan 60 | ivan@bpdm.dev | Admin | Engineering | active | 173 |
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
// …more rows than fit in 260px → the body scrolls
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function ScrollingTable() {
return <DataTable columns={columns} data={members} rowKey={(r) => r.id} stickyHeader maxHeight={260} />;
}import { Component } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'scrolling-table',
imports: [BpdmDataTable],
template: `<bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" stickyHeader [maxHeight]="260" />`,
})
export class ScrollingTable {
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
}Column toggle
columnToggle adds a "Columns" control to show/hide columns at runtime (set a
column hideable: false to keep it always visible).
Member | Role | Team | Status | actions |
|---|---|---|---|---|
Milo Lindberg | Owner | Engineering | active | |
Leo Martins | Admin | Design | invited | |
Sara Kovac | Editor | Engineering | active | |
Noah Bauer | Viewer | Support | disabled | |
Ava Nguyen | Editor | Marketing | active | |
Ivan Petrov | Admin | Engineering | active |
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', hideable: false, accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function ToggleableTable() {
return <DataTable columns={columns} data={members} rowKey={(r) => r.id} columnToggle />;
}import { Component } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'toggleable-table',
imports: [BpdmDataTable],
template: `<bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" columnToggle />`,
})
export class ToggleableTable {
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', hideable: false, accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
}Reordering
reorderableColumns lets users drag column headers; reorderableRows adds a drag
handle per row (requires rowKey). Persist via onColumnOrderChange / onRowReorder.
| Reorder | Name | Email | Role | Team | Status | Tasks |
|---|---|---|---|---|---|---|
Milo Lindberg | milo@bpdm.dev | Owner | Engineering | active | 128 | |
Leo Martins | leo@bpdm.dev | Admin | Design | invited | 0 | |
Sara Kovac | sara@bpdm.dev | Editor | Engineering | active | 86 | |
Noah Bauer | noah@bpdm.dev | Viewer | Support | disabled | 12 | |
Ava Nguyen | ava@bpdm.dev | Editor | Marketing | active | 54 | |
Ivan Petrov | ivan@bpdm.dev | Admin | Engineering | active | 203 |
import { useState } from 'react';
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const initial: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function ReorderableTable() {
const [rows, setRows] = useState(initial);
return (
<DataTable
columns={columns}
data={rows}
rowKey={(r) => r.id}
reorderableColumns
reorderableRows
onRowReorder={setRows}
/>
);
}import { Component, signal } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'reorderable-table',
imports: [BpdmDataTable],
template: `
<bpdm-data-table
[columns]="columns"
[data]="rows()"
[rowKey]="rowKey"
reorderableColumns
reorderableRows
(rowReorder)="rows.set($event)"
/>
`,
})
export class ReorderableTable {
readonly rowKey = (r: Member) => r.id;
readonly rows = signal<Member[]>([
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
]);
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
}Footer summary
Give a column a footer — a node, or a function over the filtered rows to compute
an aggregate. Any column with a footer renders a sticky summary row.
Name | Role | Team | Tasks |
|---|---|---|---|
| Milo Lindberg | Owner | Engineering | 128 |
| Leo Martins | Admin | Design | 0 |
| Sara Kovac | Editor | Engineering | 86 |
| Noah Bauer | Viewer | Support | 12 |
| Ava Nguyen | Editor | Marketing | 54 |
| Ivan Petrov | Admin | Engineering | 203 |
| Total | 483 |
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name, footer: 'Total' },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks,
footer: (rows) => rows.reduce((sum, r) => sum + r.tasks, 0) },
];
export function SummaryTable() {
return <DataTable columns={columns} data={members} rowKey={(r) => r.id} />;
}In Angular a column's footer is a string or (rows) => string.
import { Component } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'summary-table',
imports: [BpdmDataTable],
template: `<bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" />`,
})
export class SummaryTable {
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name, footer: 'Total' },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks,
footer: (rows) => String(rows.reduce((sum, r) => sum + r.tasks, 0)) },
];
}Custom styling
Style the table to fit your product. Three hooks cover most needs:
headerClassName— classes on every header cell (tint or colour the header row).rowClassName— a string, or a function(row, i) => stringfor conditional rows (e.g. flag a deactivated user with a destructive tint).column.className— classes on a column's header and cells, to accent a key column.
These compose with cellClassName (every body cell) and className (the table). Below,
the header is tinted, the Tasks column is accented, and disabled members are flagged.
Theming, not styling. The table's focus accents — row hover, the selected-row
tint + left bar, and the active-sort arrow — all follow your --primary token, so
they adopt your brand automatically. Override it once and every table re-tints:
:root { --primary: #2563eb; } /* your brand → blue hover, blue accent bar */
.dark { --primary: #3b82f6; }Use the className hooks above only for one-off, per-table overrides.
Name | Email | Role | Team | Status | Tasks |
|---|---|---|---|---|---|
Milo Lindberg | milo@bpdm.dev | Owner | Engineering | active | 128 |
Leo Martins | leo@bpdm.dev | Admin | Design | invited | 0 |
Sara Kovac | sara@bpdm.dev | Editor | Engineering | active | 86 |
Noah Bauer | noah@bpdm.dev | Viewer | Support | disabled | 12 |
Ava Nguyen | ava@bpdm.dev | Editor | Marketing | active | 54 |
Ivan Petrov | ivan@bpdm.dev | Admin | Engineering | active | 203 |
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
import { Avatar } from '@bpdm/ui/avatar';
import { Badge } from '@bpdm/ui/badge';
type Member = { id: string; name: string; role: string; status: 'active' | 'disabled'; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', status: 'active', tasks: 128 },
{ id: 'm2', name: 'Noah Bauer', role: 'Viewer', status: 'disabled', tasks: 12 },
{ id: 'm3', name: 'Sara Kovac', role: 'Editor', status: 'active', tasks: 86 },
];
const columns: DataTableColumn<Member>[] = [
{
id: 'name',
header: 'Name',
sortAccessor: (r) => r.name,
cell: (r) => (
<div className="flex items-center gap-2.5">
<Avatar name={r.name} size="sm" />
<span className="font-medium">{r.name}</span>
</div>
),
},
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{
id: 'status',
header: 'Status',
align: 'center',
cell: (r) => (
<Badge variant={r.status === 'active' ? 'success' : 'neutral'} appearance="soft" dot className="capitalize">
{r.status}
</Badge>
),
},
// a column className styles its header + every cell — here a subtle accent
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks, className: 'bg-primary/[0.04]' },
];
export function CustomStyledTable() {
return (
<DataTable
columns={columns}
data={members}
rowKey={(r) => r.id}
// tint the whole header row
headerClassName="bg-muted/60"
// flag deactivated users with a destructive tint + muted text
rowClassName={(r) => (r.status === 'disabled' ? 'bg-destructive/[0.06] text-muted-foreground' : '')}
/>
);
}import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmDataTable, BpdmAvatar, BpdmBadge, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; status: 'active' | 'disabled'; tasks: number };
@Component({
selector: 'custom-styled-table',
imports: [BpdmDataTable, BpdmAvatar, BpdmBadge],
templateUrl: './data-table-custom.html',
})
export class CustomStyledTable {
readonly nameCell = viewChild.required<TemplateRef<{ $implicit: Member }>>('nameCell');
readonly statusCell = viewChild.required<TemplateRef<{ $implicit: Member }>>('statusCell');
readonly rowKey = (r: Member) => r.id;
// flag deactivated users with a destructive tint + muted text
readonly rowClass = (r: Member) => (r.status === 'disabled' ? 'bg-destructive/[0.06] text-muted-foreground' : '');
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', status: 'active', tasks: 128 },
{ id: 'm2', name: 'Noah Bauer', role: 'Viewer', status: 'disabled', tasks: 12 },
{ id: 'm3', name: 'Sara Kovac', role: 'Editor', status: 'active', tasks: 86 },
];
readonly columns = computed<DataTableColumn<Member>[]>(() => [
{ id: 'name', header: 'Name', cell: this.nameCell(), sortAccessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'status', header: 'Status', align: 'center', cell: this.statusCell() },
// a column className styles its header + every cell — here a subtle accent
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks, className: 'bg-primary/[0.04]' },
]);
}<bpdm-data-table
[columns]="columns()"
[data]="members"
[rowKey]="rowKey"
headerClassName="bg-muted/60"
[rowClassName]="rowClass"
/>
<ng-template #nameCell let-row>
<div class="flex items-center gap-2.5">
<bpdm-avatar [name]="row.name" size="sm" />
<span class="font-medium">{{ row.name }}</span>
</div>
</ng-template>
<ng-template #statusCell let-row>
<bpdm-badge [variant]="row.status === 'active' ? 'success' : 'neutral'" appearance="soft" dot class="capitalize">
{{ row.status }}
</bpdm-badge>
</ng-template>Virtualized
For very large datasets, virtualized renders only the visible rows (uses
maxHeight, default 440). Best with uniform row heights.
Name | Email | Role | Team | Status | Tasks |
|---|
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = Array.from({ length: 10000 }, (_, i) => ({
id: `m${i}`,
name: `Member ${i + 1}`,
role: i % 2 ? 'Editor' : 'Viewer',
tasks: i % 240,
}));
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function HugeTable() {
return <DataTable columns={columns} data={members} rowKey={(r) => r.id} frame virtualized maxHeight={320} />;
}import { Component } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'huge-table',
imports: [BpdmDataTable],
template: `<bpdm-data-table [columns]="columns" [data]="members" [rowKey]="rowKey" frame virtualized [maxHeight]="320" />`,
})
export class HugeTable {
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = Array.from({ length: 10000 }, (_, i) => ({
id: `m${i}`,
name: `Member ${i + 1}`,
role: i % 2 ? 'Editor' : 'Viewer',
tasks: i % 240,
}));
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
}Responsive
On narrow screens, responsive stacks each row into a label/value card instead of
scrolling horizontally. Everything else still works together — sorting, filtering,
frozen edges (Name pinned left, Actions pinned right while the middle scrolls),
expandable rows, and rich cells (avatar, avatar group, flag, row actions). On
narrow screens they all collapse into the card. Resize the preview to see it switch;
on a wide view, scroll sideways and expand a row.
| Expand | Collaborators | Status | Actions | ||||
|---|---|---|---|---|---|---|---|
Milo Lindberg | +1 | 🇸🇪Sweden | Owner | 24 | active | ||
Leo Martins | +1 | 🇧🇷Brazil | Admin | 8 | invited | ||
Sara Kovac | +1 | 🇭🇷Croatia | Editor | 16 | active | ||
Noah Bauer | +1 | 🇩🇪Germany | Viewer | 4 | disabled |
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
import { Avatar, AvatarGroup } from '@bpdm/ui/avatar';
import { Button } from '@bpdm/ui/button';
type Member = {
id: string; name: string; country: string; flag: string;
role: string; seats: number; team: string[];
};
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', country: 'Sweden', flag: '🇸🇪', role: 'Owner', seats: 24, team: ['Sara Kovac', 'Ivan Petrov'] },
{ id: 'm2', name: 'Leo Martins', country: 'Brazil', flag: '🇧🇷', role: 'Admin', seats: 8, team: ['Noah Bauer'] },
];
const Icon = ({ d }: { d: string }) => (
<svg viewBox="0 0 16 16" className="size-4" fill="none">
<path d={d} stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const columns: DataTableColumn<Member>[] = [
{
id: 'name',
header: 'Name',
pin: 'left',
width: 210,
sortable: true,
filterable: true,
sortAccessor: (r) => r.name,
cell: (r) => (
<span className="flex items-center gap-2.5">
<Avatar name={r.name} size="sm" /> {r.name}
</span>
),
},
{
id: 'team',
header: 'Collaborators',
cell: (r) => (
<AvatarGroup max={3} size="sm">
{r.team.map((n) => (
<Avatar key={n} name={n} />
))}
</AvatarGroup>
),
},
{
id: 'country',
header: 'Location',
sortable: true,
filterable: true,
filterType: 'select',
sortAccessor: (r) => r.country,
cell: (r) => (
<span className="flex items-center gap-2">
<span className="text-base leading-none">{r.flag}</span>
{r.country}
</span>
),
},
{ id: 'role', header: 'Role', sortable: true, filterable: true, filterType: 'select', accessor: (r) => r.role },
{ id: 'seats', header: 'Seats', numeric: true, sortable: true, accessor: (r) => r.seats },
{
id: 'actions',
header: 'Actions',
pin: 'right',
width: 120,
align: 'right',
hideable: false,
cell: (r) => (
<div className="flex justify-end gap-0.5">
<Button variant="secondary" appearance="ghost" size="iconSm" aria-label={`Edit ${r.name}`}>
<Icon d="M11 2.5l2.5 2.5L6 12.5 3 13l.5-3L11 2.5Z" />
</Button>
<Button variant="destructive" appearance="ghost" size="iconSm" aria-label={`Remove ${r.name}`}>
<Icon d="M3 4.5h10M6.5 4.5V3.5a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M5 4.5l.5 8a1 1 0 0 0 1 .9h3a1 1 0 0 0 1-.9l.5-8" />
</Button>
</div>
),
},
];
export function ResponsiveTable() {
return (
<DataTable
columns={columns}
data={members}
rowKey={(r) => r.id}
responsive
pinnable
expandMode="single"
renderExpanded={(r) => (
<div className="px-2 py-1 text-sm text-muted-foreground">
{r.name} manages {r.seats} seats from {r.country}.
</div>
)}
/>
);
}import { Component, viewChild, computed, type TemplateRef } from '@angular/core';
import { BpdmDataTable, BpdmAvatar, BpdmAvatarGroup, BpdmButton, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; country: string; flag: string; role: string; seats: number; team: { name: string }[] };
@Component({
selector: 'responsive-table',
imports: [BpdmDataTable, BpdmAvatar, BpdmAvatarGroup, BpdmButton],
templateUrl: './data-table-responsive.html',
})
export class ResponsiveTable {
readonly nameCell = viewChild.required<TemplateRef<{ $implicit: Member }>>('nameCell');
readonly teamCell = viewChild.required<TemplateRef<{ $implicit: Member }>>('teamCell');
readonly locationCell = viewChild.required<TemplateRef<{ $implicit: Member }>>('locationCell');
readonly actionsCell = viewChild.required<TemplateRef<{ $implicit: Member }>>('actionsCell');
readonly rowKey = (r: Member) => r.id;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', country: 'Sweden', flag: '🇸🇪', role: 'Owner', seats: 24, team: [{ name: 'Sara Kovac' }, { name: 'Ivan Petrov' }] },
{ id: 'm2', name: 'Leo Martins', country: 'Brazil', flag: '🇧🇷', role: 'Admin', seats: 8, team: [{ name: 'Noah Bauer' }] },
];
readonly columns = computed<DataTableColumn<Member>[]>(() => [
{ id: 'name', header: 'Name', pin: 'left', width: 210, sortable: true, filterable: true, sortAccessor: (r) => r.name, cell: this.nameCell() },
{ id: 'team', header: 'Collaborators', cell: this.teamCell() },
{ id: 'country', header: 'Location', sortable: true, filterable: true, filterType: 'select', sortAccessor: (r) => r.country, cell: this.locationCell() },
{ id: 'role', header: 'Role', sortable: true, filterable: true, filterType: 'select', accessor: (r) => r.role },
{ id: 'seats', header: 'Seats', numeric: true, sortable: true, accessor: (r) => r.seats },
{ id: 'actions', header: 'Actions', pin: 'right', width: 120, align: 'right', hideable: false, cell: this.actionsCell() },
]);
}<bpdm-data-table
[columns]="columns()" [data]="members" [rowKey]="rowKey"
responsive pinnable expandMode="single" [expandedTemplate]="detail" />
<ng-template #detail let-row>
<div class="px-2 py-1 text-sm text-muted-foreground">{{ row.name }} manages {{ row.seats }} seats from {{ row.country }}.</div>
</ng-template>
<ng-template #nameCell let-row>
<span class="flex items-center gap-2.5"><bpdm-avatar [name]="row.name" size="sm" /> {{ row.name }}</span>
</ng-template>
<ng-template #teamCell let-row>
<bpdm-avatar-group [users]="row.team" [max]="3" size="sm" />
</ng-template>
<ng-template #locationCell let-row>
<span class="flex items-center gap-2"><span class="text-base leading-none">{{ row.flag }}</span>{{ row.country }}</span>
</ng-template>
<ng-template #actionsCell let-row>
<div class="flex justify-end gap-0.5">
<button bpdmButton variant="secondary" appearance="ghost" size="iconSm" [attr.aria-label]="'Edit ' + row.name">
<svg viewBox="0 0 16 16" class="size-4" fill="none" aria-hidden="true">
<path d="M11 2.5l2.5 2.5L6 12.5 3 13l.5-3L11 2.5Z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button bpdmButton variant="destructive" appearance="ghost" size="iconSm" [attr.aria-label]="'Remove ' + row.name">
<svg viewBox="0 0 16 16" class="size-4" fill="none" aria-hidden="true">
<path d="M3 4.5h10M6.5 4.5V3.5a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1M5 4.5l.5 8a1 1 0 0 0 1 .9h3a1 1 0 0 0 1-.9l.5-8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</ng-template>Empty state
emptyContent is shown when data is empty.
Name | Email | Role | Team | Status | Tasks |
|---|---|---|---|---|---|
No members yet — invite your team. | |||||
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
export function EmptyTable() {
return (
<DataTable
columns={columns}
data={[]}
rowKey={(r) => r.id}
emptyContent={<div className="py-8 text-center">No members yet — invite your team.</div>}
/>
);
}In Angular, emptyContent is a string.
import { Component } from '@angular/core';
import { BpdmDataTable, type DataTableColumn } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'empty-table',
imports: [BpdmDataTable],
template: `<bpdm-data-table [columns]="columns" [data]="[]" [rowKey]="rowKey" emptyContent="No members yet — invite your team." />`,
})
export class EmptyTable {
readonly rowKey = (r: Member) => r.id;
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', accessor: (r) => r.name },
{ id: 'role', header: 'Role', accessor: (r) => r.role },
{ id: 'tasks', header: 'Tasks', numeric: true, accessor: (r) => r.tasks },
];
}API
DataTable / <bpdm-data-table>
The API is identical in both frameworks. React callbacks (onXxx) map to Angular
outputs ((xxxChange) / (xxx)); the last column notes any Angular-specific name.
| Prop | Type | Default | Description |
|---|---|---|---|
columns | DataTableColumn<T>[] | — | Required. Column definitions. |
data | T[] | — | Required. Row data. |
rowKey | (row, i) => Key | index | Stable key (needed for selection/expansion across sorting). |
size | 'sm' | 'md' | 'lg' | 'md' | Cell density. |
striped | boolean | false | Zebra striping on alternate rows. |
bordered | boolean | false | Vertical dividers between columns. |
frame | boolean | false | Outer border + rounded container (outlined card). |
divided | boolean | true | Horizontal dividers between rows. |
elevated | boolean | true | Soft drop shadow so the table gently floats. Set false for a flat, flush table (e.g. nested inside your own card). |
hoverable | boolean | true | Highlight the hovered row. |
headerClassName | string | — | Classes on every header cell. |
cellClassName | string | — | Classes on every body cell. |
rowClassName | string | (row, i) => string | — | Per-row classes — string or a function for conditional styling. See Custom styling. |
rowSpacing | number | — | Vertical gap (px) between rows — renders rows as separated, rounded filled blocks (no dividers). |
stickyHeader | boolean | false | Keep the header visible while the body scrolls (pair with maxHeight). |
maxHeight | number | string | — | Cap the height → the body scrolls vertically inside the table. |
emptyContent | ReactNode (Angular string) | 'No data' | Shown when data is empty. |
onRowClick | (row, i) => void | — | Make rows clickable (adds pointer cursor + hover). |
label | string | — | Accessible name for the table (maps to aria-label). |
multiSort | boolean | false | Allow sorting by several columns (Shift+click appends). |
sort | DataTableSort[] | — | Controlled sort state (server-side ordering). |
defaultSort | DataTableSort[] | — | Initial sort (uncontrolled). |
onSortChange | (sort) => void | — | Fired when the sort changes. Angular (sortChange). |
selectable | boolean | false | Show a selection column. |
selectionMode | 'multiple' | 'single' | 'multiple' | Checkboxes + select-all, or radios. |
selectedKeys | Key[] | — | Controlled selected row keys. |
defaultSelectedKeys | Key[] | — | Initial selection (uncontrolled). |
onSelectionChange | (keys, rows) => void | — | Fired when the selection changes. Angular (selectionChange) emits { keys, rows }. |
pagination | ClientPagination | ServerPagination | CursorPagination | — | Paging config — see below. |
renderExpanded | (row, i) => ReactNode | — | Render an expandable detail panel under a row. Angular: [expandedTemplate] (an <ng-template>). |
expandMode | 'single' | 'multiple' | 'multiple' | Only one row open at a time, or many. |
rowExpandable | (row) => boolean | — | Hide the expander for rows where this returns false. |
expandedKeys | Key[] | — | Controlled expanded row keys. |
defaultExpandedKeys | Key[] | — | Initial expanded rows (uncontrolled). |
onExpandedChange | (keys) => void | — | Fired when the expanded set changes. Angular (expandedChange). |
pinnable | boolean | false | Per-column header menu to pin/unpin at runtime. |
onColumnPinChange | (id, pin) => void | — | Fired when a column is pinned/unpinned. Angular (columnPinChange) emits { id, pin }. |
columnToggle | boolean | false | Show a "Columns" control to show/hide columns. |
filters | Record<string, ColumnFilter> | — | Controlled column filters — set for server-side filtering (table stops filtering itself). Angular [filters]. |
onFiltersChange | (filters) => void | — | Fired when a column filter changes. Angular (filtersChange). |
searchable | boolean | false | Show a global search box across all columns. |
searchPlaceholder | string | 'Search…' | Placeholder text for the search box. |
searchValue | string | — | Controlled search value — set for server-side search. Angular [searchValue]. |
onSearchChange | (value) => void | — | Fired when the search value changes. Angular (searchChange). |
responsive | boolean | false | On narrow screens, stack each row into a label/value card. |
virtualized | boolean | false | Render only visible rows (uses maxHeight, default 440); ignores pagination. |
reorderableColumns | boolean | false | Let users drag column headers to reorder columns. |
onColumnOrderChange | (order) => void | — | Fired with the new column-id order. Angular (columnOrderChange). |
reorderableRows | boolean | false | Let users drag a row handle to reorder rows (requires rowKey). |
onRowReorder | (rows) => void | — | Fired with the reordered data. Angular (rowReorder). |
messages | Partial<DataTableMessages> | English | Translate the table's built-in strings; omitted keys fall back to English. See Internationalization. |
getRowLabel | (row, i) => string | — | Accessible name for a row's selection control — appended after selectRow, e.g. "Select row: Ada Lovelace". |
className | string | — | Classes on the outer table wrapper. Angular: class. |
Pagination config
pagination takes one of three shapes. align ('between' \| 'center' \| 'end',
default 'between') is shared by all three.
ClientPagination — the table slices data itself:
| Field | Type | Default | Description |
|---|---|---|---|
mode | 'client' | 'client' | Optional — client is the default. |
pageSize | number | 10 | Rows per page. |
pageSizeOptions | number[] | — | Offer these sizes in a selector (omit to hide it). |
page | number | — | Controlled current page (1-based). |
defaultPage | number | 1 | Initial page (uncontrolled). |
onPageChange | (page) => void | — | Fired when the page changes. |
onPageSizeChange | (size) => void | — | Fired when the page size changes. |
ServerPagination — data is already the current page:
| Field | Type | Description |
|---|---|---|
mode | 'server' | Required. |
page | number | Required. Current page (1-based). |
pageSize | number | Required. Rows per page. |
total | number | Required. Total rows on the server (drives the page count). |
onPageChange | (page) => void | Required. Fetch the requested page. |
pageSizeOptions | number[] | Optional page-size selector. |
onPageSizeChange | (size) => void | Fired when the page size changes. |
CursorPagination — prev/next only (no page numbers):
| Field | Type | Description |
|---|---|---|
mode | 'cursor' | Required. |
hasNextPage / hasPreviousPage | boolean | Required. Enable/disable the buttons. |
onNextPage / onPreviousPage | () => void | Required. Advance / go back. |
rangeLabel | ReactNode (Angular string) | Optional label beside the buttons, e.g. "Showing 20 results". |
pageSize | number | Optional current page size (for the selector). |
pageSizeOptions | number[] | Optional page-size selector. |
onPageSizeChange | (size) => void | Fired when the page size changes. |
DataTableColumn<T>
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Required. Unique id — the row key and (with no header) the default label. |
header | ReactNode (Angular string) | id | Header label / node. |
accessor | (row) => ReactNode (Angular SortValue) | — | Read a plain value from the row (used when cell is not given). |
cell | (row, i) => ReactNode (Angular TemplateRef) | — | Custom cell renderer — overrides accessor. Angular uses an <ng-template>. |
footer | ReactNode | (rows) => ReactNode (Angular string | (rows) => string | TemplateRef) | — | Footer/summary cell; a function receives the filtered rows to compute an aggregate. |
align | 'left' | 'center' | 'right' | 'left' | Horizontal alignment of header + cells. |
width | number | string | — | Fixed column width, e.g. 120 or '20%'. |
numeric | boolean | false | Right-align + tabular figures (money / counts). |
pin | 'left' | 'right' | — | Freeze the column to an edge while the table scrolls horizontally. |
disablePinning | boolean | false | Hide the per-column pin menu for this column even when pinnable is on. |
hideable | boolean | true | Set false to keep the column always visible (excluded from the toggle). |
filterable | boolean | false | Show a filter menu on this column's header. |
filterType | 'text' | 'number' | 'select' | numeric → number, else text | Filter UI kind. |
filterOptions | { value, label }[] | derived | Options for a select filter (omit to derive distinct values from the data). |
sortable | boolean | false | Allow clicking the header to sort by this column. |
sortAccessor | (row) => SortValue | — | Comparable value used when sorting (needed when cell/accessor returns a node). |
className | string | — | Extra classes applied to every cell (and the header) in this column. |
Accessibility
- Renders a real semantic
<table>(thead/tbody/th scope/td) — a plain accessible table, so screen readers announce rows and columns natively. Passlabelfor the table's accessible name (maps toaria-label). - Provide a meaningful
headerper column — it's the accessible name for that column's cells. - Sortable headers are
<button>s and the header exposesaria-sort(none/ascending/descending). Clickable rows (onRowClick) are focusable and activate on Enter / Space. - Selection checkboxes/radios are labelled; select-all is a labelled header
checkbox with an indeterminate state. Pass
getRowLabelto give each row's control a unique accessible name (e.g."Select row: Ada Lovelace") instead of a repeated generic "Select row". - A polite
aria-liveregion announces changes as they happen: the active sort (e.g. "Sorted by Name ascending") and the result count after searching or filtering (e.g. "3 results"). Both messages are overridable viamessages.announceSort/messages.announceResults. - Pagination is wrapped in a
<nav>with anaria-label(default "Pagination"); its controls are labelled buttons, and the current page carriesaria-current="page". Decorative icons arearia-hidden. - Sticky header + horizontal scroll keep headers associated with their cells, and
pinned columns stay reachable. On narrow screens,
responsivestacks each row into a labelled label/value card (theheaderbecomes each field's label). - Every built-in label, operator, and pagination string is translatable via the
messagesprop — see Internationalization.
This is a semantic table, not a custom ARIA grid — it does not implement
arrow-key cell-to-cell navigation (role="grid"). That fits the vast majority of
data tables; reach for a grid only if you need spreadsheet-style cell navigation.
Internationalization
- Every built-in string is overridable via the
messagesprop — aPartial<DataTableMessages>merged over the English defaults, so you translate only the subset you need. It covers the search box, the "no results" and range text, the columns / pin / filter menus, the filter operators, the pagination labels, and thearia-liveannouncements (announceSort,announceResults). Function-valued keys (range,goToPage,announceSort,announceResults) let you localize number order and grammar. getRowLabelgives each row's selection checkbox a unique accessible name (appended after theselectRowlabel), so screen-reader users can tell rows apart.- RTL-safe: layout uses logical properties throughout — cells align with
text-start, pinned columns useinset-inline-start/end, and the chevrons, pin arrows and sort icons mirror underdir="rtl"— so the whole table flips correctly right-to-left. See the Internationalization guide.
import { DataTable, type DataTableColumn } from '@bpdm/ui/data-table';
type Member = { id: string; name: string; role: string; tasks: number };
const members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
const columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', sortable: true, filterable: true, accessor: (r) => r.name },
{ id: 'role', header: 'Rolle', filterable: true, filterType: 'select', accessor: (r) => r.role },
{ id: 'tasks', header: 'Aufgaben', numeric: true, filterable: true, accessor: (r) => r.tasks },
];
// German overrides — pass any subset; the rest fall back to English
export function GermanTable() {
return (
<DataTable
columns={columns}
data={members}
rowKey={(r) => r.id}
selectable
searchable
searchPlaceholder="Suchen…"
pagination={{ pageSize: 2, pageSizeOptions: [2, 4, 8] }}
getRowLabel={(row) => row.name}
messages={{
search: 'Suchen',
noResults: 'Keine Ergebnisse',
columns: 'Spalten',
rowsPerPage: 'Zeilen',
operators: {
contains: 'Enthält',
startsWith: 'Beginnt mit',
endsWith: 'Endet mit',
equals: 'Gleich',
notEquals: 'Ungleich',
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
},
range: (from, to, total) => `${from}–${to} von ${total}`,
}}
/>
);
}import { Component } from '@angular/core';
import { BpdmDataTable, type DataTableColumn, type DataTableMessages } from '@bpdm/ng';
type Member = { id: string; name: string; role: string; tasks: number };
@Component({
selector: 'german-table',
imports: [BpdmDataTable],
template: `
<bpdm-data-table
[columns]="columns"
[data]="members"
[rowKey]="rowKey"
selectable
searchable
searchPlaceholder="Suchen…"
[pagination]="pagination"
[getRowLabel]="rowLabel"
[messages]="messages" />
`,
})
export class GermanTable {
readonly rowKey = (r: Member) => r.id;
readonly rowLabel = (r: Member) => r.name;
readonly members: Member[] = [
{ id: 'm1', name: 'Milo Lindberg', role: 'Owner', tasks: 128 },
{ id: 'm2', name: 'Sara Kovac', role: 'Editor', tasks: 86 },
{ id: 'm3', name: 'Noah Bauer', role: 'Viewer', tasks: 12 },
];
readonly columns: DataTableColumn<Member>[] = [
{ id: 'name', header: 'Name', sortable: true, filterable: true, accessor: (r) => r.name },
{ id: 'role', header: 'Rolle', filterable: true, filterType: 'select', accessor: (r) => r.role },
{ id: 'tasks', header: 'Aufgaben', numeric: true, filterable: true, accessor: (r) => r.tasks },
];
readonly pagination = { pageSize: 2, pageSizeOptions: [2, 4, 8] };
// German overrides — pass any subset; the rest fall back to English
readonly messages: Partial<DataTableMessages> = {
search: 'Suchen',
noResults: 'Keine Ergebnisse',
columns: 'Spalten',
rowsPerPage: 'Zeilen',
operators: {
contains: 'Enthält',
startsWith: 'Beginnt mit',
endsWith: 'Endet mit',
equals: 'Gleich',
notEquals: 'Ungleich',
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
},
range: (from, to, total) => `${from}–${to} von ${total}`,
};
}