General
Data Table
Usage#
import { WdsDataTableComponent } from '@wds/angular/data-table';Examples#
Default#
columns and data are properties (in HTML set them from a script). A column reads row[key]; format turns the value into text, sortable sorts by a click on the header and align aligns the cells. page-size="0" turns pagination off.
import { Component } from '@angular/core';
import { WdsDataTableComponent } from '@wds/angular/data-table';
import type { WdsDataTableColumn } from '@wds/core/data-table';
import { PAYMENTS, STATUS_LABEL, usd, type Payment, type Status } from './payments';
@Component({
selector: 'app-data-table-default-demo',
// The table fills the width of its container.
host: { class: 'block w-full' },
imports: [WdsDataTableComponent],
template: `<wds-data-table class="w-full" label="Payments" [columns]="columns" [data]="payments" page-size="0" />`,
})
export class DataTableDefaultDemo {
protected readonly payments = PAYMENTS;
protected readonly columns: WdsDataTableColumn<Payment>[] = [
{ key: 'id', header: 'ID' },
{ key: 'email', header: 'Email', sortable: true },
{ key: 'status', header: 'Status', format: (v: Status) => STATUS_LABEL[v] },
{ key: 'amount', header: 'Amount', sortable: true, align: 'end', format: (v: number) => usd.format(v) },
];
}import '@wds/core/badge';
export type Status = 'pending' | 'processing' | 'success' | 'failed';
export interface Payment {
id: string;
amount: number;
status: Status;
email: string;
/** ISO date (`YYYY-MM-DD`). */
date?: string;
}
export const STATUSES: Status[] = ['pending', 'processing', 'success', 'failed'];
export const STATUS_LABEL: Record<Status, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};
const BADGE_VARIANT: Record<Status, string> = { pending: 'outline', processing: 'secondary', success: 'default', failed: 'destructive' };
/** Content of a status cell (`render` of a column): a DOM node, here a `wds-badge`. */
export function statusBadge(status: Status): HTMLElement {
const el = document.createElement('wds-badge');
el.setAttribute('variant', BADGE_VARIANT[status]);
el.textContent = STATUS_LABEL[status];
return el;
}
export const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const PAYMENTS: Payment[] = [
{ id: 'm5gr84i9', amount: 316, status: 'success', email: '[email protected]' },
{ id: '3u1reuv4', amount: 242, status: 'success', email: '[email protected]' },
{ id: 'derv1ws0', amount: 837, status: 'processing', email: '[email protected]' },
{ id: '5kma53ae', amount: 874, status: 'success', email: '[email protected]' },
{ id: 'bhqecj4p', amount: 721, status: 'failed', email: '[email protected]' },
];
/** A date in September 2026, one per row, going back from the 30th. */
function dateOf(i: number): string {
return `2026-09-${String(30 - (i % 30)).padStart(2, '0')}`;
}
/** 49 rows: enough for several pages. */
const NAMES = ['anna', 'brian', 'chloe', 'david', 'emma', 'felix', 'grace', 'henry', 'isla', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter', 'rose', 'sam', 'tom', 'uma', 'victor', 'zoe'];
export const MANY_PAYMENTS: Payment[] = [
...PAYMENTS.map((p, i) => ({ ...p, date: dateOf(i) })),
...NAMES.flatMap((name, i) =>
[0, 1].map((j) => ({
date: dateOf(i * 2 + j + 5),
id: `p${(i * 7 + j * 13 + 101).toString(36)}${i}${j}`,
amount: ((i * 97 + j * 211) % 950) + 25,
status: STATUSES[(i + j) % 4],
email: `${name}${j ? i + 10 : ''}@example.com`,
}))
),
];
/** `count` generated payments (such as 10,000 for virtual scrolling). */
export function manyPayments(count: number): Payment[] {
return Array.from({ length: count }, (_, i) => ({
id: `pay-${String(i + 1).padStart(5, '0')}`,
amount: ((i * 7919) % 99_000) / 100 + 10,
status: STATUSES[(i * 31) % 4],
email: `${NAMES[i % NAMES.length]}${i}@example.com`,
date: dateOf(i),
}));
}Selection, filter and actions#
selectable adds checkboxes (Select all works on the current page) and fires wds-selection-change. filterable adds a filter field that searches every column without filterable: false; column-toggle adds a menu for hiding columns. A column with actions gets a menu in every row, and the chosen item comes in wds-row-action with its row. With more rows than page-size (default 10) the table paginates.
Selected: 0 · Last action: none
import { Component, signal } from '@angular/core';
import { WdsDataTableComponent } from '@wds/angular/data-table';
import type { WdsDataTableColumn, WdsDataTableRowActionDetail } from '@wds/core/data-table';
import { MANY_PAYMENTS, statusBadge, usd, type Payment } from './payments';
@Component({
selector: 'app-data-table-full-demo',
// The table fills the width of its container.
host: { class: 'block w-full' },
imports: [WdsDataTableComponent],
template: `
<div class="flex w-full flex-col gap-3">
<wds-data-table
label="Payments"
[columns]="columns"
[data]="payments"
selectable
filterable
filter-placeholder="Filter emails…"
column-toggle
(wdsSelectionChange)="selected.set($event.selected.length)"
(wdsRowAction)="onAction($event)"
/>
<p class="m-0 text-sm text-muted-foreground">Selected: {{ selected() }} · Last action: {{ lastAction() || 'none' }}</p>
</div>
`,
})
export class DataTableFullDemo {
protected readonly payments = MANY_PAYMENTS;
protected readonly selected = signal(0);
protected readonly lastAction = signal('');
protected readonly columns: WdsDataTableColumn<Payment>[] = [
{ key: 'status', header: 'Status', render: (row) => statusBadge(row.status), filterable: false },
{ key: 'email', header: 'Email', sortable: true },
{ key: 'amount', header: 'Amount', sortable: true, align: 'end', format: (v: number) => usd.format(v), filterable: false },
{
key: 'actions',
header: 'Actions',
actions: [
{ label: 'Actions', heading: true },
{ value: 'copy', label: 'Copy payment ID' },
{ separator: true },
{ value: 'customer', label: 'View customer' },
{ value: 'details', label: 'View payment details' },
{ separator: true },
{ value: 'delete', label: 'Delete', variant: 'destructive' },
],
},
];
protected onAction({ action, row }: WdsDataTableRowActionDetail<Payment>): void {
this.lastAction.set(`${action} (${row.email})`);
if (action === 'copy') void navigator.clipboard.writeText(row.id);
}
}import '@wds/core/badge';
export type Status = 'pending' | 'processing' | 'success' | 'failed';
export interface Payment {
id: string;
amount: number;
status: Status;
email: string;
/** ISO date (`YYYY-MM-DD`). */
date?: string;
}
export const STATUSES: Status[] = ['pending', 'processing', 'success', 'failed'];
export const STATUS_LABEL: Record<Status, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};
const BADGE_VARIANT: Record<Status, string> = { pending: 'outline', processing: 'secondary', success: 'default', failed: 'destructive' };
/** Content of a status cell (`render` of a column): a DOM node, here a `wds-badge`. */
export function statusBadge(status: Status): HTMLElement {
const el = document.createElement('wds-badge');
el.setAttribute('variant', BADGE_VARIANT[status]);
el.textContent = STATUS_LABEL[status];
return el;
}
export const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const PAYMENTS: Payment[] = [
{ id: 'm5gr84i9', amount: 316, status: 'success', email: '[email protected]' },
{ id: '3u1reuv4', amount: 242, status: 'success', email: '[email protected]' },
{ id: 'derv1ws0', amount: 837, status: 'processing', email: '[email protected]' },
{ id: '5kma53ae', amount: 874, status: 'success', email: '[email protected]' },
{ id: 'bhqecj4p', amount: 721, status: 'failed', email: '[email protected]' },
];
/** A date in September 2026, one per row, going back from the 30th. */
function dateOf(i: number): string {
return `2026-09-${String(30 - (i % 30)).padStart(2, '0')}`;
}
/** 49 rows: enough for several pages. */
const NAMES = ['anna', 'brian', 'chloe', 'david', 'emma', 'felix', 'grace', 'henry', 'isla', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter', 'rose', 'sam', 'tom', 'uma', 'victor', 'zoe'];
export const MANY_PAYMENTS: Payment[] = [
...PAYMENTS.map((p, i) => ({ ...p, date: dateOf(i) })),
...NAMES.flatMap((name, i) =>
[0, 1].map((j) => ({
date: dateOf(i * 2 + j + 5),
id: `p${(i * 7 + j * 13 + 101).toString(36)}${i}${j}`,
amount: ((i * 97 + j * 211) % 950) + 25,
status: STATUSES[(i + j) % 4],
email: `${name}${j ? i + 10 : ''}@example.com`,
}))
),
];
/** `count` generated payments (such as 10,000 for virtual scrolling). */
export function manyPayments(count: number): Payment[] {
return Array.from({ length: count }, (_, i) => ({
id: `pay-${String(i + 1).padStart(5, '0')}`,
amount: ((i * 7919) % 99_000) / 100 + 10,
status: STATUSES[(i * 31) % 4],
email: `${NAMES[i % NAMES.length]}${i}@example.com`,
date: dateOf(i),
}));
}Custom cells#
render returns the cell content as text or a DOM node; it takes precedence over format. Here statusBadge() from the payments file creates a wds-badge; the other examples use it for their status columns too.
import { Component } from '@angular/core';
import { WdsDataTableComponent } from '@wds/angular/data-table';
import type { WdsDataTableColumn } from '@wds/core/data-table';
import { PAYMENTS, statusBadge, usd, type Payment } from './payments';
@Component({
selector: 'app-data-table-custom-cells-demo',
// The table fills the width of its container.
host: { class: 'block w-full' },
imports: [WdsDataTableComponent],
template: `<wds-data-table class="w-full" label="Payments with status" [columns]="columns" [data]="payments" page-size="0" />`,
})
export class DataTableCustomCellsDemo {
protected readonly payments = PAYMENTS;
protected readonly columns: WdsDataTableColumn<Payment>[] = [
{ key: 'email', header: 'Email' },
// render returns a DOM node: statusBadge (in payments.ts) creates a wds-badge.
{ key: 'status', header: 'Status', render: (row) => statusBadge(row.status) },
{ key: 'amount', header: 'Amount', align: 'end', format: (v: number) => usd.format(v) },
];
}import '@wds/core/badge';
export type Status = 'pending' | 'processing' | 'success' | 'failed';
export interface Payment {
id: string;
amount: number;
status: Status;
email: string;
/** ISO date (`YYYY-MM-DD`). */
date?: string;
}
export const STATUSES: Status[] = ['pending', 'processing', 'success', 'failed'];
export const STATUS_LABEL: Record<Status, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};
const BADGE_VARIANT: Record<Status, string> = { pending: 'outline', processing: 'secondary', success: 'default', failed: 'destructive' };
/** Content of a status cell (`render` of a column): a DOM node, here a `wds-badge`. */
export function statusBadge(status: Status): HTMLElement {
const el = document.createElement('wds-badge');
el.setAttribute('variant', BADGE_VARIANT[status]);
el.textContent = STATUS_LABEL[status];
return el;
}
export const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const PAYMENTS: Payment[] = [
{ id: 'm5gr84i9', amount: 316, status: 'success', email: '[email protected]' },
{ id: '3u1reuv4', amount: 242, status: 'success', email: '[email protected]' },
{ id: 'derv1ws0', amount: 837, status: 'processing', email: '[email protected]' },
{ id: '5kma53ae', amount: 874, status: 'success', email: '[email protected]' },
{ id: 'bhqecj4p', amount: 721, status: 'failed', email: '[email protected]' },
];
/** A date in September 2026, one per row, going back from the 30th. */
function dateOf(i: number): string {
return `2026-09-${String(30 - (i % 30)).padStart(2, '0')}`;
}
/** 49 rows: enough for several pages. */
const NAMES = ['anna', 'brian', 'chloe', 'david', 'emma', 'felix', 'grace', 'henry', 'isla', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter', 'rose', 'sam', 'tom', 'uma', 'victor', 'zoe'];
export const MANY_PAYMENTS: Payment[] = [
...PAYMENTS.map((p, i) => ({ ...p, date: dateOf(i) })),
...NAMES.flatMap((name, i) =>
[0, 1].map((j) => ({
date: dateOf(i * 2 + j + 5),
id: `p${(i * 7 + j * 13 + 101).toString(36)}${i}${j}`,
amount: ((i * 97 + j * 211) % 950) + 25,
status: STATUSES[(i + j) % 4],
email: `${name}${j ? i + 10 : ''}@example.com`,
}))
),
];
/** `count` generated payments (such as 10,000 for virtual scrolling). */
export function manyPayments(count: number): Payment[] {
return Array.from({ length: count }, (_, i) => ({
id: `pay-${String(i + 1).padStart(5, '0')}`,
amount: ((i * 7919) % 99_000) / 100 + 10,
status: STATUSES[(i * 31) % 4],
email: `${NAMES[i % NAMES.length]}${i}@example.com`,
date: dateOf(i),
}));
}Filter bar#
toolbar slot filters the rows by itself. A column with filter becomes a field of the bar: a type ('text', 'number', 'date', 'boolean') or a field such as { type: 'select', options }. The bar fires wds-change; in manual mode send its filters to the server.
import { Component } from '@angular/core';
import { WdsDataTableComponent } from '@wds/angular/data-table';
import { WdsFiltersComponent } from '@wds/angular/filters';
import type { WdsDataTableColumn } from '@wds/core/data-table';
import { MANY_PAYMENTS, STATUSES, STATUS_LABEL, statusBadge, usd, type Payment } from './payments';
@Component({
selector: 'app-data-table-filters-demo',
host: { class: 'block w-full' },
imports: [WdsDataTableComponent, WdsFiltersComponent],
template: `
<wds-data-table class="w-full" label="Payments" [columns]="columns" [data]="payments" column-toggle>
<wds-filters slot="toolbar" />
</wds-data-table>
`,
})
export class DataTableFiltersDemo {
protected readonly payments = MANY_PAYMENTS;
// `filter` puts a column in the wds-filters bar: a field type, or a field with options.
protected readonly columns: WdsDataTableColumn<Payment>[] = [
{
key: 'status',
header: 'Status',
render: (row) => statusBadge(row.status),
filter: { type: 'select', options: STATUSES.map((s) => ({ value: s, label: STATUS_LABEL[s] })) },
},
{ key: 'email', header: 'Email', sortable: true, filter: 'text' },
{ key: 'date', header: 'Date', sortable: true, filter: 'date' },
{ key: 'amount', header: 'Amount', sortable: true, align: 'end', format: (v: number) => usd.format(v), filter: 'number' },
];
}import '@wds/core/badge';
export type Status = 'pending' | 'processing' | 'success' | 'failed';
export interface Payment {
id: string;
amount: number;
status: Status;
email: string;
/** ISO date (`YYYY-MM-DD`). */
date?: string;
}
export const STATUSES: Status[] = ['pending', 'processing', 'success', 'failed'];
export const STATUS_LABEL: Record<Status, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};
const BADGE_VARIANT: Record<Status, string> = { pending: 'outline', processing: 'secondary', success: 'default', failed: 'destructive' };
/** Content of a status cell (`render` of a column): a DOM node, here a `wds-badge`. */
export function statusBadge(status: Status): HTMLElement {
const el = document.createElement('wds-badge');
el.setAttribute('variant', BADGE_VARIANT[status]);
el.textContent = STATUS_LABEL[status];
return el;
}
export const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const PAYMENTS: Payment[] = [
{ id: 'm5gr84i9', amount: 316, status: 'success', email: '[email protected]' },
{ id: '3u1reuv4', amount: 242, status: 'success', email: '[email protected]' },
{ id: 'derv1ws0', amount: 837, status: 'processing', email: '[email protected]' },
{ id: '5kma53ae', amount: 874, status: 'success', email: '[email protected]' },
{ id: 'bhqecj4p', amount: 721, status: 'failed', email: '[email protected]' },
];
/** A date in September 2026, one per row, going back from the 30th. */
function dateOf(i: number): string {
return `2026-09-${String(30 - (i % 30)).padStart(2, '0')}`;
}
/** 49 rows: enough for several pages. */
const NAMES = ['anna', 'brian', 'chloe', 'david', 'emma', 'felix', 'grace', 'henry', 'isla', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter', 'rose', 'sam', 'tom', 'uma', 'victor', 'zoe'];
export const MANY_PAYMENTS: Payment[] = [
...PAYMENTS.map((p, i) => ({ ...p, date: dateOf(i) })),
...NAMES.flatMap((name, i) =>
[0, 1].map((j) => ({
date: dateOf(i * 2 + j + 5),
id: `p${(i * 7 + j * 13 + 101).toString(36)}${i}${j}`,
amount: ((i * 97 + j * 211) % 950) + 25,
status: STATUSES[(i + j) % 4],
email: `${name}${j ? i + 10 : ''}@example.com`,
}))
),
];
/** `count` generated payments (such as 10,000 for virtual scrolling). */
export function manyPayments(count: number): Payment[] {
return Array.from({ length: count }, (_, i) => ({
id: `pay-${String(i + 1).padStart(5, '0')}`,
amount: ((i * 7919) % 99_000) / 100 + 10,
status: STATUSES[(i * 31) % 4],
email: `${NAMES[i % NAMES.length]}${i}@example.com`,
date: dateOf(i),
}));
}Column reordering#
reorderable adds a handle to the headers: drag it, or focus it and press ← or →. The new order comes in wds-column-order-change; keep it and pass it back in column-order. The actions column stays in place.
Order: status, email, date, amount
import { Component, signal } from '@angular/core';
import { WdsDataTableComponent } from '@wds/angular/data-table';
import type { WdsDataTableColumn } from '@wds/core/data-table';
import { PAYMENTS, statusBadge, usd, type Payment } from './payments';
@Component({
selector: 'app-data-table-reorder-demo',
host: { class: 'block w-full' },
imports: [WdsDataTableComponent],
template: `
<div class="flex w-full flex-col gap-3">
<wds-data-table
label="Payments"
[columns]="columns"
[data]="payments"
reorderable
page-size="0"
[columnOrder]="order()"
(wdsColumnOrderChange)="order.set($event.order)"
/>
<p class="m-0 text-sm text-muted-foreground">Order: {{ order().join(', ') }}</p>
</div>
`,
})
export class DataTableReorderDemo {
protected readonly payments = PAYMENTS.map((p, i) => ({ ...p, date: `2026-09-${30 - i}` }));
// Keep the order (such as in localStorage) and pass it back in columnOrder.
protected readonly order = signal(['status', 'email', 'date', 'amount']);
protected readonly columns: WdsDataTableColumn<Payment>[] = [
{ key: 'status', header: 'Status', render: (row) => statusBadge(row.status) },
{ key: 'email', header: 'Email', sortable: true },
{ key: 'date', header: 'Date' },
{ key: 'amount', header: 'Amount', align: 'end', format: (v: number) => usd.format(v) },
];
}import '@wds/core/badge';
export type Status = 'pending' | 'processing' | 'success' | 'failed';
export interface Payment {
id: string;
amount: number;
status: Status;
email: string;
/** ISO date (`YYYY-MM-DD`). */
date?: string;
}
export const STATUSES: Status[] = ['pending', 'processing', 'success', 'failed'];
export const STATUS_LABEL: Record<Status, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};
const BADGE_VARIANT: Record<Status, string> = { pending: 'outline', processing: 'secondary', success: 'default', failed: 'destructive' };
/** Content of a status cell (`render` of a column): a DOM node, here a `wds-badge`. */
export function statusBadge(status: Status): HTMLElement {
const el = document.createElement('wds-badge');
el.setAttribute('variant', BADGE_VARIANT[status]);
el.textContent = STATUS_LABEL[status];
return el;
}
export const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const PAYMENTS: Payment[] = [
{ id: 'm5gr84i9', amount: 316, status: 'success', email: '[email protected]' },
{ id: '3u1reuv4', amount: 242, status: 'success', email: '[email protected]' },
{ id: 'derv1ws0', amount: 837, status: 'processing', email: '[email protected]' },
{ id: '5kma53ae', amount: 874, status: 'success', email: '[email protected]' },
{ id: 'bhqecj4p', amount: 721, status: 'failed', email: '[email protected]' },
];
/** A date in September 2026, one per row, going back from the 30th. */
function dateOf(i: number): string {
return `2026-09-${String(30 - (i % 30)).padStart(2, '0')}`;
}
/** 49 rows: enough for several pages. */
const NAMES = ['anna', 'brian', 'chloe', 'david', 'emma', 'felix', 'grace', 'henry', 'isla', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter', 'rose', 'sam', 'tom', 'uma', 'victor', 'zoe'];
export const MANY_PAYMENTS: Payment[] = [
...PAYMENTS.map((p, i) => ({ ...p, date: dateOf(i) })),
...NAMES.flatMap((name, i) =>
[0, 1].map((j) => ({
date: dateOf(i * 2 + j + 5),
id: `p${(i * 7 + j * 13 + 101).toString(36)}${i}${j}`,
amount: ((i * 97 + j * 211) % 950) + 25,
status: STATUSES[(i + j) % 4],
email: `${name}${j ? i + 10 : ''}@example.com`,
}))
),
];
/** `count` generated payments (such as 10,000 for virtual scrolling). */
export function manyPayments(count: number): Payment[] {
return Array.from({ length: count }, (_, i) => ({
id: `pay-${String(i + 1).padStart(5, '0')}`,
amount: ((i * 7919) % 99_000) / 100 + 10,
status: STATUSES[(i * 31) % 4],
email: `${NAMES[i % NAMES.length]}${i}@example.com`,
date: dateOf(i),
}));
}Grouping#
group-by groups the rows by a column. A group row shows the value, the number of rows and a button that collapses the group; a column with aggregate (sum, avg, min, max, count or a function) shows its summary in the group row, formatted with format. Collapsed groups take one row of a page.
import { Component, signal } from '@angular/core';
import { WdsDataTableComponent } from '@wds/angular/data-table';
import { WDS_SELECT } from '@wds/angular/select';
import type { WdsDataTableColumn } from '@wds/core/data-table';
import { MANY_PAYMENTS, statusBadge, usd, type Payment } from './payments';
@Component({
selector: 'app-data-table-grouping-demo',
host: { class: 'block w-full' },
imports: [WdsDataTableComponent, ...WDS_SELECT],
template: `
<div class="flex w-full flex-col gap-3">
<wds-select class="w-40" aria-label="Group by" [value]="group()" (wdsChange)="group.set($event.value)">
<wds-select-item value="status">Group by status</wds-select-item>
<wds-select-item value="date">Group by date</wds-select-item>
<wds-select-item value="none">No grouping</wds-select-item>
</wds-select>
<wds-data-table label="Payments" [columns]="columns" [data]="payments" [groupBy]="group() === 'none' ? '' : group()" page-size="20" />
</div>
`,
})
export class DataTableGroupingDemo {
protected readonly payments = MANY_PAYMENTS;
protected readonly group = signal('status');
protected readonly columns: WdsDataTableColumn<Payment>[] = [
{ key: 'status', header: 'Status', render: (row) => statusBadge(row.status) },
{ key: 'email', header: 'Email', sortable: true },
{ key: 'date', header: 'Date', sortable: true },
// The group row shows the sum of the amounts (formatted with `format`).
{ key: 'amount', header: 'Amount', sortable: true, align: 'end', format: (v: number) => usd.format(v), aggregate: 'sum' },
];
}import '@wds/core/badge';
export type Status = 'pending' | 'processing' | 'success' | 'failed';
export interface Payment {
id: string;
amount: number;
status: Status;
email: string;
/** ISO date (`YYYY-MM-DD`). */
date?: string;
}
export const STATUSES: Status[] = ['pending', 'processing', 'success', 'failed'];
export const STATUS_LABEL: Record<Status, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};
const BADGE_VARIANT: Record<Status, string> = { pending: 'outline', processing: 'secondary', success: 'default', failed: 'destructive' };
/** Content of a status cell (`render` of a column): a DOM node, here a `wds-badge`. */
export function statusBadge(status: Status): HTMLElement {
const el = document.createElement('wds-badge');
el.setAttribute('variant', BADGE_VARIANT[status]);
el.textContent = STATUS_LABEL[status];
return el;
}
export const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const PAYMENTS: Payment[] = [
{ id: 'm5gr84i9', amount: 316, status: 'success', email: '[email protected]' },
{ id: '3u1reuv4', amount: 242, status: 'success', email: '[email protected]' },
{ id: 'derv1ws0', amount: 837, status: 'processing', email: '[email protected]' },
{ id: '5kma53ae', amount: 874, status: 'success', email: '[email protected]' },
{ id: 'bhqecj4p', amount: 721, status: 'failed', email: '[email protected]' },
];
/** A date in September 2026, one per row, going back from the 30th. */
function dateOf(i: number): string {
return `2026-09-${String(30 - (i % 30)).padStart(2, '0')}`;
}
/** 49 rows: enough for several pages. */
const NAMES = ['anna', 'brian', 'chloe', 'david', 'emma', 'felix', 'grace', 'henry', 'isla', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter', 'rose', 'sam', 'tom', 'uma', 'victor', 'zoe'];
export const MANY_PAYMENTS: Payment[] = [
...PAYMENTS.map((p, i) => ({ ...p, date: dateOf(i) })),
...NAMES.flatMap((name, i) =>
[0, 1].map((j) => ({
date: dateOf(i * 2 + j + 5),
id: `p${(i * 7 + j * 13 + 101).toString(36)}${i}${j}`,
amount: ((i * 97 + j * 211) % 950) + 25,
status: STATUSES[(i + j) % 4],
email: `${name}${j ? i + 10 : ''}@example.com`,
}))
),
];
/** `count` generated payments (such as 10,000 for virtual scrolling). */
export function manyPayments(count: number): Payment[] {
return Array.from({ length: count }, (_, i) => ({
id: `pay-${String(i + 1).padStart(5, '0')}`,
amount: ((i * 7919) % 99_000) / 100 + 10,
status: STATUSES[(i * 31) % 4],
email: `${NAMES[i % NAMES.length]}${i}@example.com`,
date: dateOf(i),
}));
}Editing#
editable turns its cells into buttons: a click or Enter opens an editor (text, number, or select with options). Enter or leaving the field saves, Escape cancels. The table does not change data: wds-cell-edit gives the row, the column and the new value, so replace the row yourself.
Last edit: none
import { Component, signal } from '@angular/core';
import { WdsDataTableComponent } from '@wds/angular/data-table';
import type { WdsDataTableCellEditDetail, WdsDataTableColumn } from '@wds/core/data-table';
import { PAYMENTS, STATUSES, STATUS_LABEL, statusBadge, usd, type Payment } from './payments';
@Component({
selector: 'app-data-table-editing-demo',
host: { class: 'block w-full' },
imports: [WdsDataTableComponent],
template: `
<div class="flex w-full flex-col gap-3">
<wds-data-table label="Payments" [columns]="columns" [data]="payments()" page-size="0" (wdsCellEdit)="onEdit($event)" />
<p class="m-0 text-sm text-muted-foreground">Last edit: {{ lastEdit() || 'none' }}</p>
</div>
`,
})
export class DataTableEditingDemo {
protected readonly payments = signal<Payment[]>(PAYMENTS);
protected readonly lastEdit = signal('');
protected readonly columns: WdsDataTableColumn<Payment>[] = [
{ key: 'id', header: 'ID' },
{ key: 'email', header: 'Email', editable: true },
{
key: 'status',
header: 'Status',
render: (row) => statusBadge(row.status),
editable: true,
options: STATUSES.map((s) => ({ value: s, label: STATUS_LABEL[s] })),
},
{ key: 'amount', header: 'Amount', align: 'end', format: (v: number) => usd.format(v), editable: true, width: '9rem' },
];
// The table does not change data: replace the edited row yourself.
protected onEdit({ key, column, value }: WdsDataTableCellEditDetail<Payment>): void {
this.payments.update((rows) => rows.map((row) => (row.id === key ? { ...row, [column]: value } : row)));
this.lastEdit.set(`${column} of ${key} → ${value}`);
}
}import '@wds/core/badge';
export type Status = 'pending' | 'processing' | 'success' | 'failed';
export interface Payment {
id: string;
amount: number;
status: Status;
email: string;
/** ISO date (`YYYY-MM-DD`). */
date?: string;
}
export const STATUSES: Status[] = ['pending', 'processing', 'success', 'failed'];
export const STATUS_LABEL: Record<Status, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};
const BADGE_VARIANT: Record<Status, string> = { pending: 'outline', processing: 'secondary', success: 'default', failed: 'destructive' };
/** Content of a status cell (`render` of a column): a DOM node, here a `wds-badge`. */
export function statusBadge(status: Status): HTMLElement {
const el = document.createElement('wds-badge');
el.setAttribute('variant', BADGE_VARIANT[status]);
el.textContent = STATUS_LABEL[status];
return el;
}
export const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const PAYMENTS: Payment[] = [
{ id: 'm5gr84i9', amount: 316, status: 'success', email: '[email protected]' },
{ id: '3u1reuv4', amount: 242, status: 'success', email: '[email protected]' },
{ id: 'derv1ws0', amount: 837, status: 'processing', email: '[email protected]' },
{ id: '5kma53ae', amount: 874, status: 'success', email: '[email protected]' },
{ id: 'bhqecj4p', amount: 721, status: 'failed', email: '[email protected]' },
];
/** A date in September 2026, one per row, going back from the 30th. */
function dateOf(i: number): string {
return `2026-09-${String(30 - (i % 30)).padStart(2, '0')}`;
}
/** 49 rows: enough for several pages. */
const NAMES = ['anna', 'brian', 'chloe', 'david', 'emma', 'felix', 'grace', 'henry', 'isla', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter', 'rose', 'sam', 'tom', 'uma', 'victor', 'zoe'];
export const MANY_PAYMENTS: Payment[] = [
...PAYMENTS.map((p, i) => ({ ...p, date: dateOf(i) })),
...NAMES.flatMap((name, i) =>
[0, 1].map((j) => ({
date: dateOf(i * 2 + j + 5),
id: `p${(i * 7 + j * 13 + 101).toString(36)}${i}${j}`,
amount: ((i * 97 + j * 211) % 950) + 25,
status: STATUSES[(i + j) % 4],
email: `${name}${j ? i + 10 : ''}@example.com`,
}))
),
];
/** `count` generated payments (such as 10,000 for virtual scrolling). */
export function manyPayments(count: number): Payment[] {
return Array.from({ length: count }, (_, i) => ({
id: `pay-${String(i + 1).padStart(5, '0')}`,
amount: ((i * 7919) % 99_000) / 100 + 10,
status: STATUSES[(i * 31) % 4],
email: `${NAMES[i % NAMES.length]}${i}@example.com`,
date: dateOf(i),
}));
}Expandable rows#
renderDetail returns the content of an expanded row (text or a DOM node); every row gets a button that shows it in a full-width row below. The content is placed in the page, so utility classes work in it. rowExpandable decides which rows can expand; expanded and wds-expanded-change keep the open rows.
import { Component, signal } from '@angular/core';
import { WdsDataTableComponent } from '@wds/angular/data-table';
import type { WdsDataTableColumn } from '@wds/core/data-table';
import { MANY_PAYMENTS, statusBadge, usd, type Payment } from './payments';
@Component({
selector: 'app-data-table-expandable-demo',
host: { class: 'block w-full' },
imports: [WdsDataTableComponent],
template: `
<wds-data-table
class="w-full"
label="Payments"
[columns]="columns"
[data]="payments"
page-size="0"
[renderDetail]="detail"
[rowExpandable]="expandable"
[expanded]="expanded()"
(wdsExpandedChange)="expanded.set($event.expanded)"
/>
`,
})
export class DataTableExpandableDemo {
protected readonly payments = MANY_PAYMENTS.slice(0, 6);
protected readonly expanded = signal<string[]>([]);
protected readonly columns: WdsDataTableColumn<Payment>[] = [
{ key: 'status', header: 'Status', render: (row) => statusBadge(row.status) },
{ key: 'email', header: 'Email' },
{ key: 'amount', header: 'Amount', align: 'end', format: (v: number) => usd.format(v) },
];
// The content goes into the page (not the table's shadow DOM), so utility classes work in it.
protected readonly detail = (row: Payment): Node => {
const list = document.createElement('dl');
list.className = 'm-0 grid grid-cols-[auto_1fr] gap-x-6 gap-y-1';
for (const [term, value] of [['Payment ID', row.id], ['Date', row.date ?? ''], ['Customer', row.email]]) {
const dt = document.createElement('dt');
dt.className = 'text-muted-foreground';
dt.textContent = term;
const dd = document.createElement('dd');
dd.className = 'm-0';
dd.textContent = value;
list.append(dt, dd);
}
return list;
};
// Only failed payments have no details here.
protected readonly expandable = (row: Payment) => row.status !== 'failed';
}import '@wds/core/badge';
export type Status = 'pending' | 'processing' | 'success' | 'failed';
export interface Payment {
id: string;
amount: number;
status: Status;
email: string;
/** ISO date (`YYYY-MM-DD`). */
date?: string;
}
export const STATUSES: Status[] = ['pending', 'processing', 'success', 'failed'];
export const STATUS_LABEL: Record<Status, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};
const BADGE_VARIANT: Record<Status, string> = { pending: 'outline', processing: 'secondary', success: 'default', failed: 'destructive' };
/** Content of a status cell (`render` of a column): a DOM node, here a `wds-badge`. */
export function statusBadge(status: Status): HTMLElement {
const el = document.createElement('wds-badge');
el.setAttribute('variant', BADGE_VARIANT[status]);
el.textContent = STATUS_LABEL[status];
return el;
}
export const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const PAYMENTS: Payment[] = [
{ id: 'm5gr84i9', amount: 316, status: 'success', email: '[email protected]' },
{ id: '3u1reuv4', amount: 242, status: 'success', email: '[email protected]' },
{ id: 'derv1ws0', amount: 837, status: 'processing', email: '[email protected]' },
{ id: '5kma53ae', amount: 874, status: 'success', email: '[email protected]' },
{ id: 'bhqecj4p', amount: 721, status: 'failed', email: '[email protected]' },
];
/** A date in September 2026, one per row, going back from the 30th. */
function dateOf(i: number): string {
return `2026-09-${String(30 - (i % 30)).padStart(2, '0')}`;
}
/** 49 rows: enough for several pages. */
const NAMES = ['anna', 'brian', 'chloe', 'david', 'emma', 'felix', 'grace', 'henry', 'isla', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter', 'rose', 'sam', 'tom', 'uma', 'victor', 'zoe'];
export const MANY_PAYMENTS: Payment[] = [
...PAYMENTS.map((p, i) => ({ ...p, date: dateOf(i) })),
...NAMES.flatMap((name, i) =>
[0, 1].map((j) => ({
date: dateOf(i * 2 + j + 5),
id: `p${(i * 7 + j * 13 + 101).toString(36)}${i}${j}`,
amount: ((i * 97 + j * 211) % 950) + 25,
status: STATUSES[(i + j) % 4],
email: `${name}${j ? i + 10 : ''}@example.com`,
}))
),
];
/** `count` generated payments (such as 10,000 for virtual scrolling). */
export function manyPayments(count: number): Payment[] {
return Array.from({ length: count }, (_, i) => ({
id: `pay-${String(i + 1).padStart(5, '0')}`,
amount: ((i * 7919) % 99_000) / 100 + 10,
status: STATUSES[(i * 31) % 4],
email: `${NAMES[i % NAMES.length]}${i}@example.com`,
date: dateOf(i),
}));
}Virtual scrolling#
virtual renders only the rows in view, here 10,000 of them. The rows scroll under a sticky header in an area of --wds-data-table-height (default 400px); the column layout is fixed, so give the columns a width. Rows are measured as they render, so grouping and expanded rows work too; row-height is the first estimate.
import { Component } from '@angular/core';
import { WdsDataTableComponent } from '@wds/angular/data-table';
import type { WdsDataTableColumn } from '@wds/core/data-table';
import { manyPayments, STATUS_LABEL, usd, type Payment, type Status } from './payments';
@Component({
selector: 'app-data-table-virtual-demo',
host: { class: 'block w-full' },
imports: [WdsDataTableComponent],
template: `
<wds-data-table
class="w-full"
label="Payments"
[columns]="columns"
[data]="payments"
virtual
page-size="0"
selectable
filterable
filter-placeholder="Filter 10,000 payments…"
/>
`,
})
export class DataTableVirtualDemo {
protected readonly payments = manyPayments(10_000);
// A fixed column layout: give the columns a width.
protected readonly columns: WdsDataTableColumn<Payment>[] = [
{ key: 'id', header: 'ID', width: '7rem' },
{ key: 'email', header: 'Email', sortable: true },
{ key: 'status', header: 'Status', width: '7rem', format: (v: Status) => STATUS_LABEL[v] },
{ key: 'date', header: 'Date', width: '7rem', sortable: true },
{ key: 'amount', header: 'Amount', width: '8rem', sortable: true, align: 'end', format: (v: number) => usd.format(v) },
];
}import '@wds/core/badge';
export type Status = 'pending' | 'processing' | 'success' | 'failed';
export interface Payment {
id: string;
amount: number;
status: Status;
email: string;
/** ISO date (`YYYY-MM-DD`). */
date?: string;
}
export const STATUSES: Status[] = ['pending', 'processing', 'success', 'failed'];
export const STATUS_LABEL: Record<Status, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};
const BADGE_VARIANT: Record<Status, string> = { pending: 'outline', processing: 'secondary', success: 'default', failed: 'destructive' };
/** Content of a status cell (`render` of a column): a DOM node, here a `wds-badge`. */
export function statusBadge(status: Status): HTMLElement {
const el = document.createElement('wds-badge');
el.setAttribute('variant', BADGE_VARIANT[status]);
el.textContent = STATUS_LABEL[status];
return el;
}
export const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const PAYMENTS: Payment[] = [
{ id: 'm5gr84i9', amount: 316, status: 'success', email: '[email protected]' },
{ id: '3u1reuv4', amount: 242, status: 'success', email: '[email protected]' },
{ id: 'derv1ws0', amount: 837, status: 'processing', email: '[email protected]' },
{ id: '5kma53ae', amount: 874, status: 'success', email: '[email protected]' },
{ id: 'bhqecj4p', amount: 721, status: 'failed', email: '[email protected]' },
];
/** A date in September 2026, one per row, going back from the 30th. */
function dateOf(i: number): string {
return `2026-09-${String(30 - (i % 30)).padStart(2, '0')}`;
}
/** 49 rows: enough for several pages. */
const NAMES = ['anna', 'brian', 'chloe', 'david', 'emma', 'felix', 'grace', 'henry', 'isla', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter', 'rose', 'sam', 'tom', 'uma', 'victor', 'zoe'];
export const MANY_PAYMENTS: Payment[] = [
...PAYMENTS.map((p, i) => ({ ...p, date: dateOf(i) })),
...NAMES.flatMap((name, i) =>
[0, 1].map((j) => ({
date: dateOf(i * 2 + j + 5),
id: `p${(i * 7 + j * 13 + 101).toString(36)}${i}${j}`,
amount: ((i * 97 + j * 211) % 950) + 25,
status: STATUSES[(i + j) % 4],
email: `${name}${j ? i + 10 : ''}@example.com`,
}))
),
];
/** `count` generated payments (such as 10,000 for virtual scrolling). */
export function manyPayments(count: number): Payment[] {
return Array.from({ length: count }, (_, i) => ({
id: `pay-${String(i + 1).padStart(5, '0')}`,
amount: ((i * 7919) % 99_000) / 100 + 10,
status: STATUSES[(i * 31) % 4],
email: `${NAMES[i % NAMES.length]}${i}@example.com`,
date: dateOf(i),
}));
}Server-side#
manual the table does not sort, filter or paginate: it keeps page, sort-key and filter, fires wds-page-change, wds-sort-change and wds-filter-change, and shows the rows it is given. row-count is the total for the page count; loading shows a spinner over the rows. After a new sort or filter the table goes back to page 1.
import { Component, signal } from '@angular/core';
import { WdsDataTableComponent } from '@wds/angular/data-table';
import type { WdsDataTableColumn, WdsDataTableSortDirection } from '@wds/core/data-table';
import { fetchPayments } from './fake-server';
import { statusBadge, usd, type Payment } from './payments';
@Component({
selector: 'app-data-table-server-demo',
// The table fills the width of its container.
host: { class: 'block w-full' },
imports: [WdsDataTableComponent],
template: `
<wds-data-table
class="w-full"
label="Payments from the server"
manual
filterable
filter-placeholder="Search…"
[columns]="columns"
[data]="rows()"
[rowCount]="total()"
[page]="page()"
[pageSize]="pageSize()"
[sortKey]="sortKey()"
[sortDirection]="sortDirection()"
[loading]="loading()"
(wdsPageChange)="page.set($event.page); pageSize.set($event.pageSize); load()"
(wdsSortChange)="sortKey.set($event.sort?.key ?? ''); sortDirection.set($event.sort?.direction ?? 'asc'); load()"
(wdsFilterChange)="filter.set($event.filter); load()"
/>
`,
})
export class DataTableServerDemo {
protected readonly columns: WdsDataTableColumn<Payment>[] = [
{ key: 'id', header: 'ID' },
{ key: 'email', header: 'Email', sortable: true },
{ key: 'status', header: 'Status', render: (row) => statusBadge(row.status) },
{ key: 'amount', header: 'Amount', sortable: true, align: 'end', format: (v: number) => usd.format(v) },
];
protected readonly rows = signal<Payment[]>([]);
protected readonly total = signal(0);
protected readonly page = signal(1);
protected readonly pageSize = signal(10);
protected readonly sortKey = signal('');
protected readonly sortDirection = signal<WdsDataTableSortDirection>('asc');
protected readonly filter = signal('');
protected readonly loading = signal(false);
private request = 0;
constructor() {
this.load();
}
protected async load(): Promise<void> {
// Only the latest request updates the table.
const id = ++this.request;
this.loading.set(true);
const result = await fetchPayments({
page: this.page(),
pageSize: this.pageSize(),
sortKey: this.sortKey(),
sortDirection: this.sortDirection(),
filter: this.filter(),
});
if (id !== this.request) return;
this.rows.set(result.rows);
this.total.set(result.total);
this.loading.set(false);
}
}import '@wds/core/badge';
export type Status = 'pending' | 'processing' | 'success' | 'failed';
export interface Payment {
id: string;
amount: number;
status: Status;
email: string;
/** ISO date (`YYYY-MM-DD`). */
date?: string;
}
export const STATUSES: Status[] = ['pending', 'processing', 'success', 'failed'];
export const STATUS_LABEL: Record<Status, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};
const BADGE_VARIANT: Record<Status, string> = { pending: 'outline', processing: 'secondary', success: 'default', failed: 'destructive' };
/** Content of a status cell (`render` of a column): a DOM node, here a `wds-badge`. */
export function statusBadge(status: Status): HTMLElement {
const el = document.createElement('wds-badge');
el.setAttribute('variant', BADGE_VARIANT[status]);
el.textContent = STATUS_LABEL[status];
return el;
}
export const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const PAYMENTS: Payment[] = [
{ id: 'm5gr84i9', amount: 316, status: 'success', email: '[email protected]' },
{ id: '3u1reuv4', amount: 242, status: 'success', email: '[email protected]' },
{ id: 'derv1ws0', amount: 837, status: 'processing', email: '[email protected]' },
{ id: '5kma53ae', amount: 874, status: 'success', email: '[email protected]' },
{ id: 'bhqecj4p', amount: 721, status: 'failed', email: '[email protected]' },
];
/** A date in September 2026, one per row, going back from the 30th. */
function dateOf(i: number): string {
return `2026-09-${String(30 - (i % 30)).padStart(2, '0')}`;
}
/** 49 rows: enough for several pages. */
const NAMES = ['anna', 'brian', 'chloe', 'david', 'emma', 'felix', 'grace', 'henry', 'isla', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter', 'rose', 'sam', 'tom', 'uma', 'victor', 'zoe'];
export const MANY_PAYMENTS: Payment[] = [
...PAYMENTS.map((p, i) => ({ ...p, date: dateOf(i) })),
...NAMES.flatMap((name, i) =>
[0, 1].map((j) => ({
date: dateOf(i * 2 + j + 5),
id: `p${(i * 7 + j * 13 + 101).toString(36)}${i}${j}`,
amount: ((i * 97 + j * 211) % 950) + 25,
status: STATUSES[(i + j) % 4],
email: `${name}${j ? i + 10 : ''}@example.com`,
}))
),
];
/** `count` generated payments (such as 10,000 for virtual scrolling). */
export function manyPayments(count: number): Payment[] {
return Array.from({ length: count }, (_, i) => ({
id: `pay-${String(i + 1).padStart(5, '0')}`,
amount: ((i * 7919) % 99_000) / 100 + 10,
status: STATUSES[(i * 31) % 4],
email: `${NAMES[i % NAMES.length]}${i}@example.com`,
date: dateOf(i),
}));
}import { MANY_PAYMENTS, type Payment } from './payments';
export interface PaymentQuery {
page: number;
pageSize: number;
sortKey: string;
sortDirection: 'asc' | 'desc';
filter: string;
}
/** Stands in for an API call: filters, sorts and slices on the "server" after 800 ms. */
export function fetchPayments(query: PaymentQuery): Promise<{ rows: Payment[]; total: number }> {
return new Promise((resolve) =>
setTimeout(() => {
const text = query.filter.toLowerCase();
let rows = MANY_PAYMENTS.filter((p) => !text || p.email.toLowerCase().includes(text) || p.id.includes(text));
if (query.sortKey) {
const key = query.sortKey as keyof Payment;
const dir = query.sortDirection === 'desc' ? -1 : 1;
rows = [...rows].sort((a, b) => ((a[key] ?? '') > (b[key] ?? '') ? dir : (a[key] ?? '') < (b[key] ?? '') ? -dir : 0));
}
const start = (query.page - 1) * query.pageSize;
resolve({ rows: rows.slice(start, start + query.pageSize), total: rows.length });
}, 800)
);
}Adding rows#
toolbar slot adds items to the bar above the table, and empty-text (or the empty slot) replaces the text of an empty table. In Angular the form opens in a drawer from WdsDrawerService; HTML and React have no service, so their tabs declare the drawer in the markup.
import { Component, inject, signal } from '@angular/core';
import { email, FormField, form, required, submit, validate } from '@angular/forms/signals';
import { WdsButtonComponent } from '@wds/angular/button';
import { WdsDataTableComponent } from '@wds/angular/data-table';
import { WdsDrawerService, WdsOverlayRef } from '@wds/angular/drawer';
import { WDS_FIELD } from '@wds/angular/field';
import { WdsSelectField, WdsTextField } from '@wds/angular/forms';
import { WdsInputComponent } from '@wds/angular/input';
import { WDS_SELECT } from '@wds/angular/select';
import type { WdsDataTableColumn, WdsDataTableRowActionDetail } from '@wds/core/data-table';
import { STATUSES, STATUS_LABEL, statusBadge, usd, type Payment, type Status } from './payments';
/** "12.50" and "12,50" → 12.5; NaN when it is not a number. */
const parseAmount = (text: string) => Number(text.trim().replace(/\s/g, '').replace(',', '.'));
/** Content of the drawer: a form that closes the drawer with the new payment. */
@Component({
selector: 'app-payment-form',
host: { class: 'block' },
imports: [...WDS_FIELD, ...WDS_SELECT, FormField, WdsInputComponent, WdsSelectField, WdsTextField],
template: `
<form class="grid gap-5" novalidate (submit)="$event.preventDefault(); save()">
<wds-field [invalid]="showError(payment.email)">
<wds-field-label>Customer email</wds-field-label>
<wds-input type="email" autocomplete="email" placeholder="[email protected]" [formField]="payment.email" />
<wds-field-error [errors]="showError(payment.email) ? payment.email().errors() : []" />
</wds-field>
<wds-field [invalid]="showError(payment.amount)">
<wds-field-label>Amount (USD)</wds-field-label>
<wds-input inputmode="decimal" placeholder="0.00" [formField]="payment.amount" />
<wds-field-error [errors]="showError(payment.amount) ? payment.amount().errors() : []" />
</wds-field>
<wds-field>
<wds-field-label>Status</wds-field-label>
<wds-select class="w-full" [formField]="payment.status">
@for (s of statuses; track s) {
<wds-select-item [value]="s">{{ statusLabel[s] }}</wds-select-item>
}
</wds-select>
</wds-field>
<!-- Enter in a field submits the form. -->
<button type="submit" hidden></button>
</form>
`,
})
export class PaymentForm {
private readonly ref = inject<WdsOverlayRef<PaymentForm, Payment>>(WdsOverlayRef);
protected readonly statuses = STATUSES;
protected readonly statusLabel = STATUS_LABEL;
protected readonly model = signal<{ email: string; amount: string; status: Status }>({ email: '', amount: '', status: 'pending' });
protected readonly payment = form(this.model, (p) => {
required(p.email, { message: 'Enter the customer email.' });
email(p.email, { message: 'Enter a valid email address.' });
required(p.amount, { message: 'Enter an amount.' });
validate(p.amount, ({ value }) => {
if (!value()) return undefined;
const amount = parseAmount(value());
if (Number.isNaN(amount)) return { kind: 'number', message: 'The amount must be a number, such as 249.99.' };
if (amount <= 0) return { kind: 'min', message: 'The amount must be greater than zero.' };
return undefined;
});
});
/** An error shows after leaving the field or after an attempt to save. */
protected showError(field: () => { touched(): boolean; invalid(): boolean }): boolean {
return field().touched() && field().invalid();
}
/** Validates, and closes the drawer with the new payment when the data is valid. */
save(): void {
// submit() marks the fields as touched and runs the action only for a valid form.
void submit(this.payment, async () => {
const value = this.model();
this.ref.close({ id: crypto.randomUUID().slice(0, 8), email: value.email.trim(), amount: parseAmount(value.amount), status: value.status });
return undefined;
});
}
}
@Component({
selector: 'app-data-table-add-rows-demo',
// The table fills the width of its container.
host: { class: 'block w-full' },
imports: [WdsButtonComponent, WdsDataTableComponent],
template: `
<wds-data-table
class="w-full"
label="Added payments"
[columns]="columns"
[data]="rows()"
empty-text="No payments yet."
filterable
filter-placeholder="Filter emails…"
(wdsRowAction)="remove($event)"
>
<wds-button slot="toolbar" (click)="add()">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14" /><path d="M12 5v14" /></svg>
Add payment
</wds-button>
</wds-data-table>
`,
})
export class DataTableAddRowsDemo {
private readonly drawers = inject(WdsDrawerService);
protected readonly rows = signal<Payment[]>([]);
protected readonly columns: WdsDataTableColumn<Payment>[] = [
{ key: 'email', header: 'Email', sortable: true },
{ key: 'status', header: 'Status', render: (row) => statusBadge(row.status), filterable: false },
{ key: 'amount', header: 'Amount', sortable: true, align: 'end', format: (v: number) => usd.format(v), filterable: false },
{ key: 'actions', header: 'Actions', actions: [{ value: 'delete', label: 'Delete', variant: 'destructive' }] },
];
protected add(): void {
this.drawers
.open<PaymentForm, Payment>(PaymentForm, {
placement: 'right',
size: 'min(28rem, 100vw - 16px)',
title: 'Add payment',
description: 'Fill in the details and save. The payment appears in the table.',
cancelText: 'Cancel',
okText: 'Save payment',
// The form closes the drawer itself once it is valid (ref.close(payment)).
onOk: (f) => {
f?.save();
return false;
},
})
.closed.subscribe((payment) => {
if (payment) this.rows.update((rows) => [payment, ...rows]);
});
}
protected remove({ row }: WdsDataTableRowActionDetail<Payment>): void {
this.rows.update((rows) => rows.filter((r) => r.id !== row.id));
}
}import '@wds/core/badge';
export type Status = 'pending' | 'processing' | 'success' | 'failed';
export interface Payment {
id: string;
amount: number;
status: Status;
email: string;
/** ISO date (`YYYY-MM-DD`). */
date?: string;
}
export const STATUSES: Status[] = ['pending', 'processing', 'success', 'failed'];
export const STATUS_LABEL: Record<Status, string> = {
pending: 'Pending',
processing: 'Processing',
success: 'Success',
failed: 'Failed',
};
const BADGE_VARIANT: Record<Status, string> = { pending: 'outline', processing: 'secondary', success: 'default', failed: 'destructive' };
/** Content of a status cell (`render` of a column): a DOM node, here a `wds-badge`. */
export function statusBadge(status: Status): HTMLElement {
const el = document.createElement('wds-badge');
el.setAttribute('variant', BADGE_VARIANT[status]);
el.textContent = STATUS_LABEL[status];
return el;
}
export const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
export const PAYMENTS: Payment[] = [
{ id: 'm5gr84i9', amount: 316, status: 'success', email: '[email protected]' },
{ id: '3u1reuv4', amount: 242, status: 'success', email: '[email protected]' },
{ id: 'derv1ws0', amount: 837, status: 'processing', email: '[email protected]' },
{ id: '5kma53ae', amount: 874, status: 'success', email: '[email protected]' },
{ id: 'bhqecj4p', amount: 721, status: 'failed', email: '[email protected]' },
];
/** A date in September 2026, one per row, going back from the 30th. */
function dateOf(i: number): string {
return `2026-09-${String(30 - (i % 30)).padStart(2, '0')}`;
}
/** 49 rows: enough for several pages. */
const NAMES = ['anna', 'brian', 'chloe', 'david', 'emma', 'felix', 'grace', 'henry', 'isla', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter', 'rose', 'sam', 'tom', 'uma', 'victor', 'zoe'];
export const MANY_PAYMENTS: Payment[] = [
...PAYMENTS.map((p, i) => ({ ...p, date: dateOf(i) })),
...NAMES.flatMap((name, i) =>
[0, 1].map((j) => ({
date: dateOf(i * 2 + j + 5),
id: `p${(i * 7 + j * 13 + 101).toString(36)}${i}${j}`,
amount: ((i * 97 + j * 211) % 950) + 25,
status: STATUSES[(i + j) % 4],
email: `${name}${j ? i + 10 : ''}@example.com`,
}))
),
];
/** `count` generated payments (such as 10,000 for virtual scrolling). */
export function manyPayments(count: number): Payment[] {
return Array.from({ length: count }, (_, i) => ({
id: `pay-${String(i + 1).padStart(5, '0')}`,
amount: ((i * 7919) % 99_000) / 100 + 10,
status: STATUSES[(i * 31) % 4],
email: `${NAMES[i % NAMES.length]}${i}@example.com`,
date: dateOf(i),
}));
}API References#
wds-data-table#
columns and data properties that sorts, filters, paginates, hides columns and
selects rows by itself. In manual mode the server does these operations: the table only fires events
and shows the page of data it is given. Data grid features: column reordering (reorderable), grouping
(group-by), cell editing (editable columns), expandable rows (renderDetail) and virtual scrolling
(virtual). A wds-filters bar in the toolbar slot filters the rows.
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
| row-key.rowKey | string | 'id' | |
| selectable | boolean | false | |
| selected | string[] | ||
| filterable | boolean | false | filterable: false). |
| filter | string | '' | |
| filter-placeholder.filterPlaceholder | string | 'Filtruj…' | |
| column-toggle.columnToggle | boolean | false | |
| hidden-columns.hiddenColumns | string[] | ||
| reorderable | boolean | false | |
| column-order.columnOrder | string[] | columns). In HTML a space-separated list. | |
| group-by.groupBy | string | '' | aggregates). |
| expanded | string[] | ||
| virtual | boolean | false | page-size="0"). The rows scroll
under a sticky header in an area of --wds-data-table-height; columns get a fixed layout, so give them a width. |
| row-height.rowHeight | number | 37 | virtual: the estimated row height in px (rows are measured as they render). |
| sort-key.sortKey | string | '' | data). |
| sort-direction.sortDirection | WdsDataTableSortDirection | 'asc' | |
| page | number | 1 | |
| page-size.pageSize | number | 10 | 0 turns pagination off. |
| page-sizes.pageSizes | number[] | [10, 20, 25, 30, 40, 50] | |
| manual | boolean | false | |
| row-count.rowCount | number | 0 | manual mode: the total number of rows (for the page count). |
| empty-text.emptyText | string | 'Brak wyników.' | |
| loading | boolean | false | manual mode). The table then has aria-busy. |
| loading-text.loadingText | string | 'Ładowanie…' | |
| label | string | '' | |
| locale | string | '' |
Properties
| Property | Description |
|---|---|
| columnsWdsDataTableColumn[] | WdsDataTableColumn[]). |
| dataany[] | manual mode only the current page). |
| filtersWdsFilter[] | WdsFilter[], joined with AND): field is a column key. Set by a wds-filters bar in the
toolbar slot; in manual mode only kept (send them to the server). |
| renderDetail(row: any) => string | Node | undefined | detail-<row key>), so page styles apply. |
| rowExpandable(row: any) => boolean | undefined | renderDetail is set). |
Events
| Event | Description |
|---|---|
| wds-sort-changeCustomEvent<WdsDataTableSortChangeDetail> | |
| wds-filter-changeCustomEvent<WdsDataTableFilterChangeDetail> | |
| wds-page-changeCustomEvent<WdsDataTablePageChangeDetail> | |
| wds-selection-changeCustomEvent<WdsDataTableSelectionChangeDetail> | |
| wds-visibility-changeCustomEvent<WdsDataTableVisibilityChangeDetail> | |
| wds-column-order-changeCustomEvent<WdsDataTableColumnOrderChangeDetail> | |
| wds-expanded-changeCustomEvent<WdsDataTableExpandedChangeDetail> | |
| wds-cell-editCustomEvent<WdsDataTableCellEditDetail> | data with the new value. |
| wds-row-actionCustomEvent<WdsDataTableRowActionDetail> |
Slots
| Slot | Description |
|---|---|
| toolbar | wds-filters bar). |
| empty | empty-text). |
| loading | loading-text). |
CSS parts (::part)
| Part | Description |
|---|---|
| toolbar | |
| filter | |
| container | virtual also vertically). |
| table | <table> element. |
| footer | |
| loading |
CSS custom properties
| Property | Description |
|---|---|
| --wds-data-table-height | virtual (default 400px). |
Accessibility#
Screen reader#
<table> with <th scope="col"> headers; label gives it an accessible name. While loading it has aria-busy="true" and the spinner has role="status".
<th>, which has aria-sort (ascending or descending) on the sorted column.
filter-placeholder. After typing stops, the number of results is announced in a live region; the selection count below the table is also a live region.
Zaznacz wiersz 1, Wierszy na stronę); empty-text, filter-placeholder and loading-text can be changed.
actions has a visually hidden header text (its header, default Akcje).
reorderable, the header handle is a button (Przesuń kolumnę Email) described by the arrow-key hint; the new position is announced (Email: kolumna 2 z 4).
aria-expanded; the expand button of a row has aria-expanded and aria-controls pointing to the detail row.
[email protected], edytuj: Email); the editor is named by the column header.
virtual the table has aria-rowcount and every rendered row aria-rowindex, so screen readers know the position of the rows in view.
Keyboard support#
| Key | Function |
|---|---|
reorderable): moves the column one place. | |