ComponentDirectiveStandalone

Table

Data table with sortable / filterable headers and custom cell templates.

Installation

import { WrTable, WrTableCell, type WrTableColumns } from 'ngwr/table';

@Component({ imports: [WrTable, WrTableCell] })
export class MyComponent {}

// Each projected template is its own directive, and `imports: []` takes the
// class, not the selector. Add the ones your template actually uses:
//   <ng-template wrTableCell>        -> WrTableCell
//   <ng-template wrTableExpand>      -> WrTableExpand
//   <ng-template wrTableGroupHeader> -> WrTableGroupHeader

Basic usage

Define columns as a record keyed by row property names. Rows are plain objects.

Name
Email
Role
Roman [email protected] admin
Alice [email protected] editor
Bob [email protected] viewer
Cara [email protected] editor
Diego [email protected] viewer
<wr-table [columns]="columns" [items]="rows" />

Sort + filter + custom cell

Sortable columns cycle asc → desc → off. Filter dropdown emits the selected items via filterChange. Project an [wrTableCell] template to customize a column's rendering.

Name
Email
Role
Roman [email protected] admin
Alice [email protected] editor
Bob [email protected] viewer
Cara [email protected] editor
Diego [email protected] viewer
<wr-table [columns]="columns" [items]="rows" [(sort)]="sort" (filterChange)="onFilter($event)">
  <ng-template wrTableCell="role" let-value>
    <wr-tag [color]="value === 'admin' ? 'danger' : 'medium'">{{ value }}</wr-tag>
  </ng-template>
</wr-table>

Sorting is state, not a sort

The header cycles asc → desc → off and writes the result into [(sort)]. That is all it does: the table never reorders items, in any mode — the same contract groupBy states about grouping. The rows in the demo above move because THIS page sorts them, and that comparator is the part a docs snippet usually leaves out, so it is spelled in full below. A table bound to a plain array with no comparator behind it shows the arrow flipping and the rows standing still, which reads as a broken control rather than as a contract.

// The table writes `sort` and reads nothing back from your rows. Sorting
// them is yours — this is the comparator the demo above runs.
protected readonly sort = signal<readonly WrTableSortState[]>([]);

protected readonly rows = computed(() => {
  const rules = this.sort();
  if (rules.length === 0) return this.source();

  return [...this.source()].sort((a, b) => {
    for (const { key, direction } of rules) {   // array order = application order
      if (!direction) continue;                 // a header cycled back to "off"
      const cmp = String(a[key] ?? '').localeCompare(String(b[key] ?? ''));
      if (cmp !== 0) return direction === 'asc' ? cmp : -cmp;
    }
    return 0;
  });
});

// Server-side, the same array becomes query parameters — still no client sort.
effect(() => {
  const [primary] = this.sort();
  this.load({ sortBy: primary?.key, order: primary?.direction });
});

sort is an array, not a single rule: each sortable header owns its own entry, a second header appends rather than replaces, and array order is application order — first entry is the primary key. A header cycled back to off drops its entry. That is why the comparator above loops instead of reading sort()[0].

The control is the sort button, not the whole cell: .wr-table__sort-btn, a real <button> named from sortLabel (or the table.sort catalog key). Clicking the title text or the header padding does nothing, so an end-to-end test drives the button — getByRole('button', { name: 'Sort column' }) — and reads the result off the header's aria-sort, which is ascending / descending / none on every sortable column and absent on the rest.

Sorting also never touches page. Clicking a header on page 3 leaves you on page 3, and in server-side mode that means the next request goes out with the new order and the old offset. Resetting to page 1 on a sort change is a policy, not a default — write it in the handler that reacts to the sort, the same place you build the query.

What a cell template can read

A [wrTableCell] template gets three context members, and only the value is implicit. The row itself is let-row="item" — a bare let-row binds the value a second time, which is the quiet way to get an undefined in a click handler.

<!-- The cell template's context is `WrTableCellContext`: three names,
     and only the first is implicit.

       let-value             the cell value — item[columnKey]
       let-row="item"        the WHOLE row object
       let-col="column"      that column's own definition (title, width, …)

     `let-row` alone binds `$implicit`, i.e. the value again — the row needs
     the explicit `="item"`. -->
<ng-template wrTableCell="role" let-value let-row="item" let-col="column">
  {{ col.title }}: {{ value }} — {{ row.email }}
</ng-template>

Row actions

The column key is just a key — give the action column one nothing in the row answers to, leave its title empty, and read the row off the template context. Deleting here mutates the demo's own list; reload the section to get the rows back.

Name
Email
Roman [email protected] Delete
Alice [email protected] Delete
Bob [email protected] Delete
Cara [email protected] Delete
Diego [email protected] Delete
Restore rows
// A column whose cells are buttons: give it a key of its own and no
// title-bearing data behind it. `let-row="item"` is what the handler needs —
// the cell value for an action column is `undefined`, and that is fine.
const columns: WrTableColumns = {
  name: { title: 'Name' },
  email: { title: 'Email' },
  actions: { title: '', width: 96 },
};

<wr-table [columns]="columns" [items]="rows">
  <ng-template wrTableCell="actions" let-row="item">
    <wr-btn size="sm" color="danger" outlined (click)="remove(row)">Delete</wr-btn>
  </ng-template>
</wr-table>

Loading, empty — and error

[loading] and emptyLabel are the two states the table draws itself. There is no error input, and that is deliberate: a failed request belongs to the page, not to the table. Render a <wr-alert> beside it and let the table show its empty state.

<!-- Two states are built in. `[loading]` draws the spinner overlay;
     `items` that is empty (or null) draws the empty row, whose copy is
     `emptyLabel` — or the `table.empty` i18n key when you leave it unset. -->
<wr-table [columns]="columns" [items]="rows()" [loading]="loading()" emptyLabel="No users yet" />

<!-- There is deliberately NO error input. A failed request is the page's
     state, not the table's, so render it beside the table and pass the empty
     list through: -->
@if (error(); as message) {
  <wr-alert type="danger" title="Could not load users" [message]="message" />
}
<wr-table [columns]="columns" [items]="rows()" [loading]="loading()" />

Pagination

Set [pageSize] for client-side pagination — the table slices rows automatically and renders a <wr-pagination> footer. Pass [total] to switch to server-side mode (you handle slicing).

Name
Email
Role
User 01 [email protected] admin
User 02 [email protected] viewer
User 03 [email protected] viewer
User 04 [email protected] editor
User 05 [email protected] viewer
1 2 3 4 5
<wr-table [columns]="columns" [items]="rows" [pageSize]="5" [(page)]="page" />

Server-side paging — what to bind, what fires

Setting [total] switches the footer pager to server mode: the table stops slicing and renders whatever items you hand it. page is a model(), so the event it publishes is (pageChange) — that is the output to listen to, and there is no separately named one. Bind it either way: [(page)] when the page number is your own signal, or [page] + (pageChange) when the click has to go through a handler that builds the request.

<!-- `[total]` switches the pager to server mode; `[pageSize]` still has to
     be there or no pager renders at all. `page` is a model, so the output
     it publishes is `(pageChange)`. -->
<wr-table
  [columns]="columns"
  [items]="pageRows()"
  [total]="total()"
  [pageSize]="20"
  [page]="page()"
  (pageChange)="onPage($event)"
  [(sort)]="sort"
/>
// The handler. Nothing is sliced for you — you fetch the window you were asked for.
protected onPage(page: number): void {
  this.page.set(page);
  this.load();                 // limit: 20, skip: (page - 1) * 20
}

// Two-way binding works just as well when the page number is your own state:
//   <wr-table [total]="total()" [pageSize]="20" [(page)]="page" />
// …with an effect on `page` issuing the request.

[pageSize] is still required in server mode. It no longer slices anything, but the footer renders only while pageSize > 0 and total exceeds it — a table given [total] alone shows no pager at all, which looks like the server contract being ignored. Pass the same number you send as the request's limit.

The footer is a plain <wr-pagination> with the page, size and total wired through — nothing else. It carries no showTotal range label and no page-size changer, and the table exposes no input to turn either on. When you need those, leave pageSize at 0 so the table renders no footer of its own and put your own <wr-pagination> underneath: it is the same component, with showTotal, showSizeChanger and [(pageSize)] available. That page also documents the two behaviours a server-paged host meets first — what happens while total is still 0 between requests, and who clamps the page after a size change.

Virtual scroll

Set virtualScroll with a fixed viewportHeight to window thousands of rows — only the visible slice is in the DOM. It forces fixed column layout, assumes a uniform rowHeight (auto-measured by default), and falls back to the full render while grouping, tree rows, expandable rows, responsive cards or a pager is active. The table below holds 10,000 rows.

<wr-table
  virtualScroll
  [rowHeight]="41"
  [viewportHeight]="440"
  [columns]="columns"
  [items]="rows"
/>
ID
Name
Email
Role
1 User 00001 [email protected] admin
2 User 00002 [email protected] viewer
3 User 00003 [email protected] viewer
4 User 00004 [email protected] editor
5 User 00005 [email protected] viewer
6 User 00006 [email protected] viewer
7 User 00007 [email protected] editor
8 User 00008 [email protected] admin
9 User 00009 [email protected] viewer
10 User 00010 [email protected] editor
11 User 00011 [email protected] viewer
12 User 00012 [email protected] viewer
13 User 00013 [email protected] editor
14 User 00014 [email protected] viewer
15 User 00015 [email protected] admin
16 User 00016 [email protected] editor
17 User 00017 [email protected] viewer
18 User 00018 [email protected] viewer

The number to pass. A body row is one line of text plus the cell padding and the row rule — 1.25rem line-height, 0.625rem of padding on each side multiplied by --wr-density-y, and 1px of border. At a 16px root that is 41px at md, and 32 / 48 / 55px at sm / lg / touch. Left at 0 the table measures the first rendered row and uses that, so it is right without being told; the value matters when you pass one, because the server has nothing to measure and falls back to a flat 40. Passing 40 for an md table is therefore off by a pixel per row — the prerendered window drifts a whole row every ~40 of them, until the client measurement replaces it. A taller custom cell template moves these numbers, and the table cannot know: measure once and pass what you measured.

Row selection

Set rowSelection to 'multiple' (checkboxes + a select-all header) or 'single'. Bind [(selection)] to the selected row keys, and rowKey to identify rows (a property name or a function).

<wr-table
  rowSelection="multiple"
  rowKey="email"
  [(selection)]="selected"
  [columns]="columns"
  [items]="rows"
/>
Name
Email
Role
Roman [email protected] admin
Alice [email protected] editor
Bob [email protected] viewer
Cara [email protected] editor
Diego [email protected] viewer
selected: 0

Expandable rows

Project an <ng-template wrTableExpand let-row> to reveal a detail panel per row — it adds a leading chevron column. Bind [(expanded)] (row keys) and set rowKey.

<wr-table rowKey="email" [(expanded)]="expanded" [columns]="columns" [items]="rows">
  <ng-template wrTableExpand let-row>
    <p>{{ row.name }} — {{ row.email }}</p>
  </ng-template>
</wr-table>
Name
Email
Role
Roman [email protected] admin
Alice [email protected] editor
Bob [email protected] viewer
Cara [email protected] editor
Diego [email protected] viewer

Summary row

Give a column a summary — a built-in aggregate ('sum' / 'avg' / 'count' / 'min' / 'max') over its numeric values, or a function of all rows. A footer row appears with the results (computed over the current items).

const columns: WrTableColumns = {
  product: { title: 'Product', summary: () => 'Total' },
  price:   { title: 'Price', summary: 'avg' },
  qty:     { title: 'Qty', summary: 'sum' },
};
Product
Price
Qty
Widget 19.99 3
Gadget 49.5 1
Gizmo 8.75 12
Doohickey 120 2
Total 49.56 18

Row grouping

Set groupBy to a row property (or a function) to gather rows under collapsible band headers. Grouping runs after pagination — sort by the same key, or use pageSize = 0, to keep each group whole. Bind [(collapsedGroups)] (keyed by group value) to control or persist collapse. Scroll a pinned table sideways and the group label stays pinned to the left edge like a frozen column.

<wr-table
  groupBy="role"
  [(collapsedGroups)]="collapsed"
  [columns]="columns"
  [items]="rows"
/>
Name
Email
Role
Roman [email protected] admin
Alice [email protected] editor
Cara [email protected] editor
Bob [email protected] viewer
Diego [email protected] viewer

Group subtotals

Add groupSummary to repeat each column's summary aggregate as a subtotal row under every group, computed over that group's rows. The grand footer still totals the whole dataset — with client-side pagination the two describe different scopes, so pair groupSummary with pageSize = 0.

const columns: WrTableColumns = {
  region:  { title: 'Region', summary: () => 'Subtotal' },
  rep:     { title: 'Rep' },
  deals:   { title: 'Deals',   summary: 'sum' },
  revenue: { title: 'Revenue', summary: 'sum' },
};

<wr-table groupBy="region" groupSummary [columns]="columns" [items]="rows" />
Region
Rep
Deals
Revenue
EMEA Alice 12 48200
EMEA Bob 7 21500
Subtotal 19 69700
AMER Cara 15 61000
AMER Diego 9 33750
Subtotal 24 94750
APAC Emi 11 40100
Subtotal 11 40100
Subtotal 54 204550

Custom group header

Project an <ng-template wrTableGroupHeader> for a rich band label — it receives the group value, label, rows, count, collapsed and a toggle() callback. The chevron and the group checkbox are still rendered by the table, so the template may contain its own interactive content.

<wr-table groupBy="role" [columns]="columns" [items]="rows">
  <ng-template wrTableGroupHeader let-value let-count="count">
    <wr-tag color="primary" transparent>{{ value }}</wr-tag>
    <small>{{ count }} users</small>
  </ng-template>
</wr-table>
Name
Email
Role
admin1 users
Roman [email protected] admin
editor2 users
Alice [email protected] editor
Cara [email protected] editor
viewer2 users
Bob [email protected] viewer
Diego [email protected] viewer

CSV export

Call exportCsv() on the table (via a template ref) to download its rows — headers from the column titles, values from the row data, in the current column order. It covers the current items, so in server-side mode (total set) that's the current page. { selectedOnly: true } exports just the selected rows; toCsv() returns the string instead of downloading.

<wr-table #table [columns]="columns" [items]="rows" />

<wr-btn (click)="table.exportCsv({ filename: 'users.csv' })">Export CSV</wr-btn>
Name
Email
Role
Roman [email protected] admin
Alice [email protected] editor
Bob [email protected] viewer
Cara [email protected] editor
Diego [email protected] viewer
Export CSV

Pinned columns

Set pin: 'left' or pin: 'right' on a column to freeze it against that edge while the rest scrolls horizontally. Several per side stack in order, and offsets are measured so columns of any width line up. Scroll the table below sideways — Name stays left, Status stays right.

const columns: WrTableColumns = {
  name:   { title: 'Name', pin: 'left' },
  email:  { title: 'Email' },
  role:   { title: 'Role' },
  // …more columns in between…
  status: { title: 'Status', pin: 'right' },
};
Name
Email
Role
Department
Location
Joined
Status
Roman [email protected] admin Engineering Almaty 2021-03-12 Active
Alice [email protected] editor Design Berlin 2022-07-01 Active
Bob [email protected] viewer Support Toronto 2023-01-19 Invited
Cara [email protected] editor Marketing São Paulo 2020-11-05 Active
Diego [email protected] viewer Sales Madrid 2024-02-28 Suspended

Resizable columns

Set resizable: true for a drag handle on the header's right edge. The first drag freezes the current widths and switches the table to fixed layout so resizing is exact in both directions; width sets an initial px width.

const columns: WrTableColumns = {
  name:  { title: 'Name', resizable: true },
  email: { title: 'Email', resizable: true, width: 240 },
  role:  { title: 'Role', resizable: true },
};
Name
Email
Role
Roman [email protected] admin
Alice [email protected] editor
Bob [email protected] viewer
Cara [email protected] editor
Diego [email protected] viewer

Reorderable columns

Set reorderable to drag column headers into a new order. Bind [(columnOrder)] (an array of keys) to control or persist the arrangement. Pinned columns stay anchored — they aren't draggable.

<wr-table reorderable [(columnOrder)]="order" [columns]="columns" [items]="rows" />
Name
Email
Role
Roman [email protected] admin
Alice [email protected] editor
Bob [email protected] viewer
Cara [email protected] editor
Diego [email protected] viewer

Narrow container (stacked cards)

With responsive, each row collapses to a labelled card when the table's own box is too narrow for columns — a container query on its own width, so it adapts inside a split pane on any screen. Every cell shows its column title as a label. The box below is fixed at 360px.

Name
Email
Role
Roman [email protected] admin
Alice [email protected] editor
Bob [email protected] viewer
Cara [email protected] editor
Diego [email protected] viewer
<div style="width: 360px"><wr-table responsive [columns]="columns" [items]="rows" /></div>

Tree rows

Set childrenKey to the property (or a function) holding each row's children and items becomes the roots. The forest is flattened into the same <tbody>, so child rows are ordinary <tr>s — column pin, resize, drag-reorder and [wrTableCell] templates all keep working at every depth. Open state reuses [(expanded)] and identity reuses rowKey. treeColumn picks which column carries the indent and chevron; without it the first rendered column does. pageSize pages the roots, selection and the summary row see every node, and the table announces itself as a treegrid with aria-level / aria-posinset / aria-setsize per row. Three pairings are refused rather than half-supported: groupBy wins over childrenKey, since a group buckets a flat list and a forest has none; a projected [wrTableExpand] loses to tree rows, which already own the row's disclosure; and virtualScroll falls back to the full render, because expanding a parent re-anchors the window.

<!-- `childrenKey` names the child array; `items` becomes the roots.
     Open state reuses `[(expanded)]` and identity reuses `rowKey`. -->
<wr-table
  rowKey="id"
  childrenKey="reports"
  treeColumn="name"
  rowSelection="multiple"
  [columns]="orgColumns"
  [items]="org"
  [(expanded)]="openRows"
  [(selection)]="picked"
/>
Team / person
Reports to
Headcount
Engineering 0
4

Types

Data shapes used by the inputs and outputs above.

type WrTableColumns = Record<string, WrTableColumn>;

interface WrTableColumn {
  title: string;
  sortable?: boolean;
  filterItems?: readonly WrTableFilterItem[];
  pin?: 'left' | 'right';
  resizable?: boolean;
  width?: number;
  summary?: WrTableSummary;
}

interface WrTableFilterItem<T = unknown> {
  title: string;
  value: T;
  selected?: boolean;
}

interface WrTableSortState {
  key: string;
  direction: 'asc' | 'desc' | null;
}

interface WrTableCsvOptions {
  filename?: string;
  selectedOnly?: boolean;
  delimiter?: string;
  escapeFormulas?: boolean;
}

interface WrTableGroupContext {
  value: unknown;
  label: string;
  rows: readonly Record<string, unknown>[];
  count: number;
  collapsed: boolean;
  index: number;
  toggle: () => void;
}
NameDescriptionTypeDefault
WrTableColumnsColumn map — keys are row property names.Record<string, WrTableColumn>
WrTableColumnA single column definition.interface
titlerequiredHeading shown in the header.string
sortableShow a clickable sort indicator.booleanfalse
filterItemsNon-empty list shows a filter dropdown.readonly WrTableFilterItem[]
pinFreeze the column against the 'left' or 'right' edge while the rest scrolls.'left' | 'right'
resizableAdd a drag handle on the header edge to resize.booleanfalse
widthInitial column width in px (overridden by a drag).number
summaryFooter aggregate — 'sum' / 'avg' / 'count' / 'min' / 'max', or (rows) => value.WrTableSummary
WrTableFilterItemOne entry in a column filter.interface
titlerequiredVisible label.string
valuerequiredValue matched against the cell.T
selectedPre-check the entry.booleanfalse
WrTableSortStateEmitted by (sortChange).{ key: string; direction: WrTableSortDirection }
WrTableCsvOptionsArgument of exportCsv() / toCsv().interface
filenameDownload filename.string'table.csv'
selectedOnlyExport only the selected rows (needs rowSelection).booleanfalse
delimiterField delimiter — use ';' where Excel expects it.string','
escapeFormulasPrefix values starting with = + - @ so spreadsheets keep them as text.booleantrue
WrTableGroupContextPassed to a wrTableGroupHeader template (also the built-in band context).interface
valueThe value groupBy returned; also the collapse identity.unknown
labelDefault label — String(value), or '—' for empty.string
rowsThe group's rows on the current page.readonly Record<string, unknown>[]
countrows.length — page-scoped row count.number
collapsedWhether the group is currently collapsed.boolean
index0-based index of the group on the current page.number
toggleCollapse / expand this group.() => void

API

NameDescriptionTypeDefault
columnsrequiredColumn definitions, keyed by row property name.WrTableColumns
itemsRow items. null/undefined renders the empty state; use [loading] for the spinner. Typed WrTableRow (= object) rather than Record<string, unknown>, so an array of an interface binds as readily as an array of a type — see {@link WrTableRow} for why those two are not the same to TypeScript.readonly WrTableRow[] | null | undefinednull
loadingShow the loading spinner overlay.booleanfalse
responsiveCollapse each row to a labelled card when the table's own box is too narrow for columns (a container query on its own width, not the viewport). Every cell shows its column title as a label.booleanfalse
reorderableEnable drag-to-reorder on the column headers.booleanfalse
columnOrderTwo-way bindable column order — an array of column keys. Reflects and drives the header order: the table falls back to declaration order for any key not listed, and updates this on drag. Bind it to persist a user's arrangement.readonly string[][]
rowSelectionRow selection with a leading checkbox column — 'multiple' adds a select-all header; 'single' keeps one row selected.'single' | 'multiple' | nullnull (off)
rowKeyHow to identify a row for selection — a property name or a function. Unset uses the row object itself (fine for a stable row array).string | ((row: Record<string, unknown>) => unknown) | nullnull
selectionTwo-way bindable selected row keys.readonly unknown[][]
expandedTwo-way bindable expanded row keys (needs a [wrTableExpand] template).readonly unknown[][]
groupByGroup rows under collapsible band headers — a row property name, or a function returning the group value. null (default) renders the table exactly as before. Grouping runs AFTER pagination: it buckets the rows on the current page in first-appearance order (it never re-sorts your data — same contract as [(sort)]), so a group straddling a page boundary shows a band on both pages, and per-group counts / subtotals are page-scoped. Sort items by the same key upstream to keep groups whole, or pair with pageSize = 0. Group values are compared by identity (Map/Set, SameValueZero) — return a primitive, exactly as for rowKey. Objects / Dates bucket by reference.string | ((row: Record<string, unknown>) => unknown) | nullnull
childrenKeyRender the rows as a hierarchy: names the property (or computes the array) holding each row's children. items then means the ROOTS, and the forest is flattened depth-first into the same <tbody> — child rows are ordinary <tr>s going through the same cell loop, so column pin / resize / drag-reorder and [wrTableCell] templates keep working at every depth. Open state reuses the expanded model and row identity reuses rowKey, so a tree needs no second key space. Everything collapsed is the default. *Mutually exclusive with groupBy** — a forest has no flat list to bucket, so grouping wins and the hierarchy is ignored while it is set. Also mutually exclusive with [wrTableExpand] detail rows: both own the row's disclosure affordance. pageSize pages the ROOTS; a root brings its open descendants with it. The function form RETURNS WrTableRows, matching [items]: a return is covariant, so an array of interface-typed children satisfies it. Declared as records it did not, and a forest that bound to [items] could not hand its children back out. The PARAMETER stays Record<string, unknown> — that side is contravariant, and widening it would reject the callbacks that compile today while leaving row['reports'] unindexable. See {@link WrTableRow}.string | ((row: Record<string, unknown>) => readonly WrTableRow[] | null | undefined) | nullnull
treeColumnWhich column carries the indent and the expand toggle. Keyed, not positional, so it survives a columnOrder drag. Defaults to whichever column renders first.string | nullnull
toggleRowLabelAccessible name of a parent row's expand toggle. Falls back to table.toggleRow.string | nullnull
collapsedGroupsTwo-way bindable collapsed group values (the values groupBy returns). Keyed by value, so a collapsed group stays collapsed across page changes and re-sorts. Empty (the default) shows every group expanded.readonly unknown[][]
groupSummaryRender an aggregate row under each group using the same column.summary definitions as the grand footer, computed over that group's rows on the current page. No-op unless at least one column defines a summary. The grand <tfoot> is unaffected and still aggregates the whole client dataset — with client-side pagination the two describe different scopes, so pair grouping with pageSize = 0 when you need them to reconcile.booleanfalse
showGroupCountShow the page-scoped row-count badge in each group band.booleantrue
sortTwo-way bindable sort array. Order in array = application order.readonly WrTableSortState[][]
emptyLabelText shown when there are no rows. Falls back to table.empty.string | nullnull
sortLabelAccessible name of a column's sort button. Falls back to table.sort.string | nullnull
selectAllLabelAccessible name of the select-all checkbox. Falls back to table.selectAll.string | nullnull
selectRowLabelAccessible name of a row's select checkbox. Falls back to table.selectRow.string | nullnull
expandRowLabelAccessible name of a row's expand toggle. Falls back to table.expandRow.string | nullnull
selectGroupLabelAccessible name of a group band's select checkbox. Falls back to table.selectGroup.string | nullnull
toggleGroupLabelAccessible name of a group band's collapse toggle. Falls back to table.toggleGroup.string | nullnull
loadingLabelAccessible name of the loading overlay. Falls back to table.loading.string | nullnull
(filterChange)Fires whenever a column's filter selection changes.WrTableFilterChange
pageSizeRows per page. Set to 0 (default) to disable client-side pagination and render every row at once.number0
pageTwo-way bindable 1-based current page.number1
totalTotal row count for server-side pagination — when set, the table shows the pager but does NOT slice items (you provide the current page's slice yourself and react to (page) changes). Spelled total / page to match <wr-pagination>, which is the control this input renders. The two used to disagree about both words — totalItems / page here against total / currentPage there — which is a real cost for two components designed to sit on one screen. null rather than 0 is the difference that remains, and it carries meaning: it is the OFF switch for server mode, where the pager's own total is a count and 0 means an empty list.number | nullnull
virtualScrollWindow the <tbody> so a table of thousands of rows keeps only ~one viewport of <tr>s in the DOM. Opt-in and OFF by default — a table without it renders byte-identically to today. Engages ONLY on the flat, fixed-height tier: it silently falls back to the full render whenever a variable-height layout is active — groupBy, a [wrTableExpand] template, responsive card mode, or a visible pager (pageSize > 0 / total). While on, it forces fixed layout for stable column widths and assumes a uniform row height (see rowHeight). Intended as static config. Give columns an explicit width so the frozen layout is exact (a virtualized table always renders fixed-layout); toggling it off at runtime leaves the frozen widths in place as column minimums.booleanfalse
rowHeightUniform body-row height in px used to map scroll offset to row index. 0 (default) measures the first rendered row once — matching the density at that point — and reuses it; pass an explicit value to remove the post-hydration measure and make SSR pixel-exact (recommended if the density can change at runtime). Read only when virtualScroll is on. Keep virtualized cells single-line and no taller than this — a taller custom [wrTableCell] template drifts the window.number0
viewportHeightHeight of the scroll viewport when virtualScroll is on — a number (px) or any CSS length ('70vh'). Applied as max-height on .wr-table__scroll (a short table shrinks to fit; a long one caps and scrolls). A numeric px value lets the server prerender the exact first window.number | string480
overscanExtra rows kept rendered above and below the viewport as scroll headroom.number6

Templates

Projected templates the table renders in place of its defaults.

NameDescriptionTypeDefault
<ng-template wrTableExpand>Detail template revealed when a row expands (let-row).directive
<ng-template wrTableGroupHeader>Custom band label template (let-value, let-count, let-toggle, …).directive
[wrTableCell]Per-column cell template. The attribute value is the column key it overrides; import WrTableCell. Its context is WrTableCellContext — three names, listed below.directive
let-valueThe cell value, item[columnKey]. The implicit context member, so the name is yours to pick.unknown
let-row="item"The whole row object. Needs the explicit ="item" — a bare let-row binds the implicit value instead. This is what a row-action button reads.Record<string, unknown>
let-col="column"That column’s own definition — title, width, align, and the rest of WrTableColumn.WrTableColumn

Methods

Called on the component instance via a template reference.

NameDescriptionTypeDefault
exportCsv(options?)Download the rows as a CSV file (options: filename, selectedOnly, delimiter).(WrTableCsvOptions) => void
toCsv(options?)Return the table as a CSV string instead of downloading.(WrTableCsvOptions) => string
collapseAllGroups() / expandAllGroups()Collapse or expand every group on the current page.() => void
scrollToRow(index, behavior?)Scroll a row index to the top of the virtual viewport.(number, ScrollBehavior) => void

<wr-table-filter>

NameDescriptionTypeDefault
filterLabelAccessible name of the filter trigger. Falls back to table.filter.string | nullnull
noMatchesLabelText shown when the search finds nothing. Falls back to table.noMatches.string | nullnull
searchLabelPlaceholder AND accessible name of the search box. Falls back to table.search. One string for both because the box has no visible label: the placeholder was its only name, so a hard-coded literal left the control unnamed in every other language rather than merely untranslated.string | nullnull
resetLabelLabel of the clear-selection button. Falls back to table.reset.string | nullnull
itemsrequiredreadonly WrTableFilterItem[]
(selectionChange)Fires whenever the selection changes.readonly WrTableFilterItem[]

<wr-table-sort>

NameDescriptionTypeDefault
directionWrTableSortDirectionnull

CSS variables

Custom properties ngwr/table publishes. Each default below is declared on the component's own selector, so a :root override is shadowed by it — set them on that selector, on a wrapper you scope yourself, or inline on the element. Unlike the BEM class names, these are the supported way to restyle the component.

VariableDefaultDeclared on
--wr-table-bgvar(--wr-color-surface).wr-table
--wr-table-bordervar(--wr-color-outline).wr-table
--wr-table-group-bgvar(--wr-color-fill-subtle).wr-table
--wr-table-head-bgvar(--wr-color-fill).wr-table
--wr-table-head-letter-spacingvar(--wr-tracking-normal).wr-table
--wr-table-head-transformnone.wr-table
--wr-table-row-hovervar(--wr-color-fill-subtle).wr-table