Data Table
A table of rows to sort, search, page through and select, on signals.
- v1.0.0
- SSR
- Zoneless
- 1.2 kB gzip
| Status | Actions | |||
|---|---|---|---|---|
| success | ken99@example.com | $316.00 | ||
| success | abe45@example.com | $242.00 | ||
| processing | monserrat44@example.com | $837.00 | ||
| success | silas22@example.com | $874.00 | ||
| failed | carmella@example.com | $721.00 |
0 of 7 rows selected.
import { CurrencyPipe } from '@angular/common';
import { Component, signal } from '@angular/core';
import { NdIcon } from '@ndop/core';
import { Button } from '@/ui/button/button';
import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox';
import {
createDataTable,
DataTableSortButton,
} from '@/ui/data-table/data-table';
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIndicator,
DropdownMenuTrigger,
} from '@/ui/dropdown-menu/dropdown-menu';
import { Input } from '@/ui/input/input';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from '@/ui/table/table';
interface Payment {
id: string;
amount: number;
status: 'pending' | 'processing' | 'success' | 'failed';
email: string;
}
/**
* Payments to sort by email or amount, search, select and page through,
* with columns to hide and a menu of actions per row. The state is
* `createDataTable`; the markup is the Table item's.
*/
@Component({
selector: 'example-data-table-demo',
imports: [
Button,
Checkbox,
CheckboxIndicator,
CurrencyPipe,
DataTableSortButton,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIndicator,
DropdownMenuTrigger,
Input,
NdIcon,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
],
template: `
<div class="w-2xl max-w-full">
<div class="flex items-center gap-2 py-4">
<input
ndInput
aria-label="Filter payments"
placeholder="Filter payments…"
class="max-w-sm"
[value]="table.query()"
(input)="table.search($any($event.target).value)"
/>
<button
ndButton
variant="outline"
class="ms-auto"
[ndDropdownMenuTrigger]="columnsMenu"
>
Columns <svg ndIcon="chevron-down"></svg>
</button>
<ng-template #columnsMenu>
<div ndDropdownMenuContent align="end">
@for (column of table.columns; track column.id) {
@if (column.hideable !== false) {
<div
ndDropdownMenuCheckboxItem
[checked]="table.isVisible(column.id)"
(checkedChange)="table.toggleColumn(column.id, $event)"
>
<span ndDropdownMenuItemIndicator>
<svg ndIcon="check"></svg>
</span>
{{ column.header }}
</div>
}
}
</div>
</ng-template>
</div>
<div ndTableContainer class="rounded-md border">
<table ndTable>
<thead ndTableHeader>
<tr ndTableRow>
<th ndTableHead class="w-10">
<button
ndCheckbox
aria-label="Select all on this page"
[checked]="table.pageSelected()"
[indeterminate]="table.pagePartlySelected()"
(checkedChange)="table.togglePage($event)"
>
<span ndCheckboxIndicator><svg ndIcon="check"></svg></span>
</button>
</th>
@for (column of table.visibleColumns(); track column.id) {
<th ndTableHead [attr.aria-sort]="table.ariaSort(column.id)">
@if (column.sortable) {
<button
ndDataTableSortButton
[table]="table"
[column]="column.id"
>
{{ column.header }}
<svg ndIcon="arrow-up-down"></svg>
</button>
} @else {
{{ column.header }}
}
</th>
}
<th ndTableHead class="w-10">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody ndTableBody>
@for (row of table.rows(); track row.id) {
<tr
ndTableRow
[attr.data-state]="table.isSelected(row.id) ? 'selected' : null"
>
<td ndTableCell>
<button
ndCheckbox
[attr.aria-label]="'Select ' + row.data.email"
[checked]="table.isSelected(row.id)"
(checkedChange)="table.toggleRow(row.id, $event)"
>
<span ndCheckboxIndicator><svg ndIcon="check"></svg></span>
</button>
</td>
@for (column of table.visibleColumns(); track column.id) {
<td ndTableCell>
@switch (column.id) {
@case ('amount') {
{{ row.data.amount | currency: 'USD' }}
}
@case ('status') {
<span class="capitalize">{{ row.data.status }}</span>
}
@default {
{{ row.data.email }}
}
}
</td>
}
<td ndTableCell>
<button
ndButton
variant="ghost"
size="icon-sm"
[ndDropdownMenuTrigger]="rowMenu"
>
<svg ndIcon="ellipsis"></svg>
<span class="sr-only"
>Actions for {{ row.data.email }}</span
>
</button>
<ng-template #rowMenu>
<div ndDropdownMenuContent align="end">
<div ndDropdownMenuItem (triggered)="copy(row.data.id)">
Copy payment ID
</div>
<div ndDropdownMenuItem>View customer</div>
</div>
</ng-template>
</td>
</tr>
} @empty {
<tr ndTableRow>
<td
ndTableCell
[attr.colspan]="table.visibleColumns().length + 2"
class="h-24 text-center"
>
No results.
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="flex items-center justify-between gap-2 py-4 text-sm">
<p class="text-muted-foreground" aria-live="polite">
{{ table.selection().size }} of {{ table.matchingRows().length }}
rows selected.
</p>
<div class="flex items-center gap-2">
<span
>Page {{ table.pageIndex() + 1 }} of {{ table.pageCount() }}</span
>
<button
ndButton
variant="outline"
size="sm"
[disabled]="!table.canPrevious()"
(click)="table.previous()"
>
Previous
</button>
<button
ndButton
variant="outline"
size="sm"
[disabled]="!table.canNext()"
(click)="table.next()"
>
Next
</button>
</div>
</div>
</div>
`,
})
export class DataTableDemoExample {
protected readonly payments = signal<readonly Payment[]>([
{
id: 'm5gr84i9',
amount: 316,
status: 'success',
email: 'ken99@example.com',
},
{
id: '3u1reuv4',
amount: 242,
status: 'success',
email: 'abe45@example.com',
},
{
id: 'derv1ws0',
amount: 837,
status: 'processing',
email: 'monserrat44@example.com',
},
{
id: '5kma53ae',
amount: 874,
status: 'success',
email: 'silas22@example.com',
},
{
id: 'bhqecj4p',
amount: 721,
status: 'failed',
email: 'carmella@example.com',
},
{
id: 'p0r8sd1k',
amount: 129,
status: 'pending',
email: 'nadia@example.com',
},
{
id: 'x7v2kq9m',
amount: 560,
status: 'success',
email: 'tomas@example.com',
},
]);
protected readonly table = createDataTable({
data: this.payments,
columns: [
{ id: 'status', header: 'Status' },
{ id: 'email', header: 'Email', sortable: true },
{ id: 'amount', header: 'Amount', sortable: true },
],
rowId: (payment) => payment.id,
pageSize: 5,
});
/**
* Copies a payment's id to the clipboard.
*
* @param id - The payment's id.
*/
protected copy(id: string): void {
void navigator.clipboard?.writeText(id);
}
}
A data table shows rows you can sort, search, page through and select,
and whose columns you can hide. Its state is createDataTable, a few
signals in your component: it renders nothing, so you mark up a native
table with the Table item's parts, a Checkbox for selection, an Input
for search and a Dropdown Menu for columns. It's all copy-in code with no
dependency, renders on the server and needs no zone.
Installation
npx @ndop/cli add data-tablepnpm dlx @ndop/cli add data-tableyarn dlx @ndop/cli add data-tablebunx @ndop/cli add data-tableInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/data-table/data-table.tsimport { computed, Directive, input } from '@angular/core'; import { type DataTable } from '@/lib/data-table'; import { buttonVariants } from '@/ui/button/button'; import { cn } from '@/lib/utils'; export { compareValues, createDataTable, type DataTable, type DataTableColumn, type DataTableDirection, type DataTableOptions, type DataTableRow, type DataTableSort, } from '@/lib/data-table'; /** * A column header's sort button: a click sorts by its column, ascending, * then descending, then not at all. Bind the header cell's `aria-sort` to * `table.ariaSort(column)`, so screen readers hear the order; the button * has `data-sort` for its icon. * * @example * ```html * <th ndTableHead [attr.aria-sort]="table.ariaSort('email')"> * <button ndDataTableSortButton [table]="table" column="email"> * Email <svg ndIcon="arrow-up-down"></svg> * </button> * </th> * ``` */ @Directive({ selector: 'button[ndDataTableSortButton]', host: { type: 'button', 'data-slot': 'data-table-sort-button', '[attr.data-sort]': 'table().ariaSort(column())', '[class]': 'classes()', '(click)': 'table().toggleSort(column())', }, }) export class DataTableSortButton { /** The table it sorts, from `createDataTable`. */ readonly table = input.required<Pick<DataTable<unknown>, 'ariaSort' | 'toggleSort'>>(); /** The id of the column it sorts by. */ readonly column = input.required<string>(); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: 'ghost', size: 'sm' }), '-ms-3 h-8 data-[sort=none]:[&>svg]:opacity-50', this.class(), ), ); }src/app/lib/data-table.tsimport { computed, signal, type Signal } from '@angular/core'; /** One column of a {@link DataTable}. */ export interface DataTableColumn<T> { /** A unique id, used for sorting, visibility and `aria-sort`. */ readonly id: string; /** The header's text, also shown where columns are listed. */ readonly header: string; /** The cell's value, for sorting and filtering; defaults to `row[id]`. */ readonly value?: (row: T) => unknown; /** Whether the header can sort by it. */ readonly sortable?: boolean; /** Whether it can be hidden; defaults to `true`. */ readonly hideable?: boolean; /** Orders two values; defaults to numbers, dates, then text. */ readonly compare?: (a: unknown, b: unknown) => number; } /** Which way a column sorts. */ export type DataTableDirection = 'ascending' | 'descending'; /** The column the rows are sorted by. */ export interface DataTableSort { /** The column's id. */ readonly id: string; /** Which way it sorts. */ readonly direction: DataTableDirection; } /** A row shown on the current page, with its id. */ export interface DataTableRow<T> { /** The row's id, for selection and `track`. */ readonly id: string; /** The row's data. */ readonly data: T; } /** What {@link createDataTable} needs. */ export interface DataTableOptions<T> { /** The rows, such as a signal or an input. */ readonly data: () => readonly T[]; /** The columns, in order. */ readonly columns: readonly DataTableColumn<T>[]; /** A row's id; defaults to its index in `data`. */ readonly rowId?: (row: T, index: number) => string; /** Rows per page; defaults to 10. */ readonly pageSize?: number; /** * Whether a row matches the search; defaults to any column's value * containing it, ignoring case. */ readonly filter?: (row: T, query: string) => boolean; } /** * A data table's state, on signals: sorting, searching, pages, selected * rows and hidden columns, with the rows to show. It renders nothing: * mark up a `table` with the Table item's parts and loop over `rows()` * and `visibleColumns()`. */ export interface DataTable<T> { /** The columns, in order. */ readonly columns: readonly DataTableColumn<T>[]; /** The search text. */ readonly query: Signal<string>; /** The sort, or `null` for the data's order. */ readonly sort: Signal<DataTableSort | null>; /** The current page, from 0. */ readonly pageIndex: Signal<number>; /** Rows per page. */ readonly pageSize: Signal<number>; /** How many pages the matching rows fill, at least 1. */ readonly pageCount: Signal<number>; /** The rows matching the search, sorted, on every page. */ readonly matchingRows: Signal<readonly DataTableRow<T>[]>; /** The rows to show: the current page of {@link DataTable.matchingRows}. */ readonly rows: Signal<readonly DataTableRow<T>[]>; /** The columns not hidden. */ readonly visibleColumns: Signal<readonly DataTableColumn<T>[]>; /** The selected rows' ids. */ readonly selection: Signal<ReadonlySet<string>>; /** Whether every row on the page is selected. */ readonly pageSelected: Signal<boolean>; /** Whether some, but not all, rows on the page are selected. */ readonly pagePartlySelected: Signal<boolean>; /** Whether there is a previous page. */ readonly canPrevious: Signal<boolean>; /** Whether there is a next page. */ readonly canNext: Signal<boolean>; /** * Sets the search text and goes back to the first page. * * @param query - What to search for. */ search(query: string): void; /** * Sorts by a column: ascending, then descending, then not at all. * * @param id - The column's id. */ toggleSort(id: string): void; /** * The `aria-sort` value for a column's header. * * @param id - The column's id. * @returns `ascending`, `descending`, or `none` for a sortable column * that isn't sorted, `null` otherwise. */ ariaSort(id: string): DataTableDirection | 'none' | null; /** * Shows a page. * * @param index - The page, from 0; kept within the pages there are. */ goTo(index: number): void; /** Shows the previous page, if any. */ previous(): void; /** Shows the next page, if any. */ next(): void; /** * Sets how many rows a page shows, and goes back to the first page. * * @param size - Rows per page. */ setPageSize(size: number): void; /** * Whether a row is selected. * * @param id - The row's id. * @returns `true` when it is. */ isSelected(id: string): boolean; /** * Selects or unselects a row. * * @param id - The row's id. * @param selected - Whether to select it; defaults to the opposite. */ toggleRow(id: string, selected?: boolean): void; /** * Selects or unselects every row on the page. * * @param selected - Whether to select them; defaults to the opposite of * {@link DataTable.pageSelected}. */ togglePage(selected?: boolean): void; /** * Whether a column is shown. * * @param id - The column's id. * @returns `true` unless it's hidden. */ isVisible(id: string): boolean; /** * Shows or hides a column, if it's hideable. * * @param id - The column's id. * @param visible - Whether to show it; defaults to the opposite. */ toggleColumn(id: string, visible?: boolean): void; } /** * The default order for two values: numbers and dates numerically, the * rest as text in the user's locale, with empty values last. * * @param a - A value. * @param b - Another value. * @returns Negative, zero or positive, as for `Array.sort`. */ export function compareValues(a: unknown, b: unknown): number { if (a === b) return 0; if (a === null || a === undefined || a === '') return 1; if (b === null || b === undefined || b === '') return -1; if (typeof a === 'number' && typeof b === 'number') return a - b; if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime(); return String(a).localeCompare(String(b), undefined, { numeric: true }); } /** * Creates a data table's state, usually in a component's field * initializer. It's all signals, so it renders on the server and needs no * zone. * * @param options - The rows, columns and defaults. * @returns The table's state. * * @example * ```ts * readonly table = createDataTable({ * data: this.payments, * columns: [ * { id: 'email', header: 'Email', sortable: true }, * { id: 'amount', header: 'Amount', sortable: true }, * ], * rowId: (payment) => payment.id, * }); * ``` */ export function createDataTable<T>(options: DataTableOptions<T>): DataTable<T> { const { columns } = options; const valueOf = (column: DataTableColumn<T>, row: T): unknown => column.value ? column.value(row) : (row as Record<string, unknown>)[column.id]; const filter = options.filter ?? ((row: T, query: string) => { const needle = query.toLowerCase(); return columns.some((column) => String(valueOf(column, row) ?? '') .toLowerCase() .includes(needle), ); }); const query = signal(''); const sort = signal<DataTableSort | null>(null); const pageIndex = signal(0); const pageSize = signal(options.pageSize ?? 10); const selection = signal<ReadonlySet<string>>(new Set()); const hidden = signal<ReadonlySet<string>>(new Set()); const allRows = computed(() => options.data().map((data, index) => ({ id: options.rowId ? options.rowId(data, index) : String(index), data, })), ); const matchingRows = computed(() => { const text = query().trim(); const matching = text ? allRows().filter((row) => filter(row.data, text)) : allRows(); const current = sort(); const column = current && columns.find((other) => other.id === current.id); if (!current || !column) { return matching; } const compare = column.compare ?? compareValues; const sign = current.direction === 'ascending' ? 1 : -1; return [...matching].sort( (a, b) => sign * compare(valueOf(column, a.data), valueOf(column, b.data)), ); }); const pageCount = computed(() => Math.max(1, Math.ceil(matchingRows().length / pageSize())), ); // The page stays within the pages there are as rows come and go. const page = computed(() => Math.min(pageIndex(), pageCount() - 1)); const rows = computed(() => matchingRows().slice(page() * pageSize(), (page() + 1) * pageSize()), ); const pageSelected = computed( () => rows().length > 0 && rows().every((row) => selection().has(row.id)), ); const pagePartlySelected = computed( () => !pageSelected() && rows().some((row) => selection().has(row.id)), ); const goTo = (index: number) => pageIndex.set(Math.max(0, Math.min(index, pageCount() - 1))); return { columns, query: query.asReadonly(), sort: sort.asReadonly(), pageIndex: page, pageSize: pageSize.asReadonly(), pageCount, matchingRows, rows, visibleColumns: computed(() => columns.filter((column) => !hidden().has(column.id)), ), selection: selection.asReadonly(), pageSelected, pagePartlySelected, canPrevious: computed(() => page() > 0), canNext: computed(() => page() < pageCount() - 1), search(text) { query.set(text); pageIndex.set(0); }, toggleSort(id) { const current = sort(); if (current?.id !== id) { sort.set({ id, direction: 'ascending' }); } else if (current.direction === 'ascending') { sort.set({ id, direction: 'descending' }); } else { sort.set(null); } }, ariaSort(id) { const column = columns.find((other) => other.id === id); if (!column?.sortable) { return null; } const current = sort(); return current?.id === id ? current.direction : 'none'; }, goTo, previous: () => goTo(page() - 1), next: () => goTo(page() + 1), setPageSize(size) { pageSize.set(Math.max(1, size)); pageIndex.set(0); }, isSelected: (id) => selection().has(id), toggleRow(id, selected) { selection.update((current) => { const next = new Set(current); if (selected ?? !current.has(id)) { next.add(id); } else { next.delete(id); } return next; }); }, togglePage(selected) { const select = selected ?? !pageSelected(); selection.update((current) => { const next = new Set(current); for (const row of rows()) { if (select) { next.add(row.id); } else { next.delete(row.id); } } return next; }); }, isVisible: (id) => !hidden().has(id), toggleColumn(id, visible) { const column = columns.find((other) => other.id === id); if (column?.hideable === false) { return; } hidden.update((current) => { const next = new Set(current); if (visible ?? current.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }, }; }src/app/ui/button/button.tsimport { computed, Directive, input } from '@angular/core'; import { NdButton } from '@ndop/primitives/button'; import { cn, variants, type VariantOf } from '@/lib/utils'; /** * Class table for {@link Button}; also usable to style other elements like * a button. */ export const buttonVariants = variants({ base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-disabled:pointer-events-none data-disabled:opacity-50", variants: { variant: { default: 'bg-primary text-primary-foreground hover:bg-primary/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40', outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', link: 'text-primary underline-offset-4 hover:underline', }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', icon: 'size-9', 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10', }, }, defaults: { variant: 'default', size: 'default' }, }); /** Visual style of a {@link Button}. */ export type ButtonVariant = VariantOf<typeof buttonVariants, 'variant'>; /** Size of a {@link Button}. */ export type ButtonSize = VariantOf<typeof buttonVariants, 'size'>; /** * Styled button for a native `button` or an `a`, with the * `@ndop/primitives/button` behavior (safe default type, disabled links). * * @example * ```html * <button ndButton variant="outline" size="sm">Cancel</button> * <a ndButton variant="link" href="/docs">Docs</a> * ``` */ @Directive({ selector: 'button[ndButton], a[ndButton]', hostDirectives: [{ directive: NdButton, inputs: ['disabled'] }], host: { 'data-slot': 'button', '[attr.data-variant]': 'variant()', '[attr.data-size]': 'size()', '[class]': 'classes()', }, }) export class Button { /** Visual style. */ readonly variant = input<ButtonVariant>('default'); /** Height, padding and icon size. */ readonly size = input<ButtonSize>('default'); /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( buttonVariants({ variant: this.variant(), size: this.size() }), this.class(), ), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import { Button } from '@/ui/button/button';
import { Checkbox, CheckboxIndicator } from '@/ui/checkbox/checkbox';
import {
createDataTable,
DataTableSortButton,
} from '@/ui/data-table/data-table';
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuItemIndicator,
DropdownMenuTrigger,
} from '@/ui/dropdown-menu/dropdown-menu';
import { Input } from '@/ui/input/input';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from '@/ui/table/table';<div class="w-2xl max-w-full">
<div class="flex items-center gap-2 py-4">
<input
ndInput
aria-label="Filter payments"
placeholder="Filter payments…"
class="max-w-sm"
[value]="table.query()"
(input)="table.search($any($event.target).value)"
/>
<button
ndButton
variant="outline"
class="ms-auto"
[ndDropdownMenuTrigger]="columnsMenu"
>
Columns <svg ndIcon="chevron-down"></svg>
</button>
<ng-template #columnsMenu>
<div ndDropdownMenuContent align="end">
@for (column of table.columns; track column.id) {
@if (column.hideable !== false) {
<div
ndDropdownMenuCheckboxItem
[checked]="table.isVisible(column.id)"
(checkedChange)="table.toggleColumn(column.id, $event)"
>
<span ndDropdownMenuItemIndicator>
<svg ndIcon="check"></svg>
</span>
{{ column.header }}
</div>
}
}
</div>
</ng-template>
</div>
<div ndTableContainer class="rounded-md border">
<table ndTable>
<thead ndTableHeader>
<tr ndTableRow>
<th ndTableHead class="w-10">
<button
ndCheckbox
aria-label="Select all on this page"
[checked]="table.pageSelected()"
[indeterminate]="table.pagePartlySelected()"
(checkedChange)="table.togglePage($event)"
>
<span ndCheckboxIndicator><svg ndIcon="check"></svg></span>
</button>
</th>
@for (column of table.visibleColumns(); track column.id) {
<th ndTableHead [attr.aria-sort]="table.ariaSort(column.id)">
@if (column.sortable) {
<button
ndDataTableSortButton
[table]="table"
[column]="column.id"
>
{{ column.header }}
<svg ndIcon="arrow-up-down"></svg>
</button>
} @else {
{{ column.header }}
}
</th>
}
<th ndTableHead class="w-10">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody ndTableBody>
@for (row of table.rows(); track row.id) {
<tr
ndTableRow
[attr.data-state]="table.isSelected(row.id) ? 'selected' : null"
>
<td ndTableCell>
<button
ndCheckbox
[attr.aria-label]="'Select ' + row.data.email"
[checked]="table.isSelected(row.id)"
(checkedChange)="table.toggleRow(row.id, $event)"
>
<span ndCheckboxIndicator><svg ndIcon="check"></svg></span>
</button>
</td>
@for (column of table.visibleColumns(); track column.id) {
<td ndTableCell>
@switch (column.id) {
@case ('amount') {
{{ row.data.amount | currency: 'USD' }}
}
@case ('status') {
<span class="capitalize">{{ row.data.status }}</span>
}
@default {
{{ row.data.email }}
}
}
</td>
}
<td ndTableCell>
<button
ndButton
variant="ghost"
size="icon-sm"
[ndDropdownMenuTrigger]="rowMenu"
>
<svg ndIcon="ellipsis"></svg>
<span class="sr-only"
>Actions for {{ row.data.email }}</span
>
</button>
<ng-template #rowMenu>
<div ndDropdownMenuContent align="end">
<div ndDropdownMenuItem (triggered)="copy(row.data.id)">
Copy payment ID
</div>
<div ndDropdownMenuItem>View customer</div>
</div>
</ng-template>
</td>
</tr>
} @empty {
<tr ndTableRow>
<td
ndTableCell
[attr.colspan]="table.visibleColumns().length + 2"
class="h-24 text-center"
>
No results.
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="flex items-center justify-between gap-2 py-4 text-sm">
<p class="text-muted-foreground" aria-live="polite">
{{ table.selection().size }} of {{ table.matchingRows().length }}
rows selected.
</p>
<div class="flex items-center gap-2">
<span
>Page {{ table.pageIndex() + 1 }} of {{ table.pageCount() }}</span
>
<button
ndButton
variant="outline"
size="sm"
[disabled]="!table.canPrevious()"
(click)="table.previous()"
>
Previous
</button>
<button
ndButton
variant="outline"
size="sm"
[disabled]="!table.canNext()"
(click)="table.next()"
>
Next
</button>
</div>
</div>
</div>Accessibility
Data Table has no keyboard interaction or focus handling of its own; it keeps the semantics of the element it's on.
API reference
DataTableSortButton
button[ndDataTableSortButton]A column header's sort button: a click sorts by its column, ascending,
then descending, then not at all. Bind the header cell's aria-sort to
table.ariaSort(column), so screen readers hear the order; the button
has data-sort for its icon.
| DataTableSortButton binding | Type | Default |
|---|---|---|
[table]requiredThe table it sorts, from createDataTable. | Pick<DataTable<unknown>, "ariaSort" | "toggleSort"> | — |
[column]requiredThe id of the column it sorts by. | string | — |
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | data-table-sort-button |
data-sort | — |
Agent notes
Do
- Bind each sortable header's
aria-sorttotable.ariaSort(id), and sort with aDataTableSortButtonin it. - Give rows a stable id with
rowId, so selection survives sorting. - Say how many rows are selected and which page is shown, in text.
Don't
- Don't use it for a few static rows; use Table.
- Don't make whole rows clickable without a link or button in them.