Table: declarative columns
Author one [forTableColumnDef] per column and <for-table-body> stamps the header row and one data row per item out of the same cell primitives, so a column is declared in one place instead of being kept in sync by hand across a header row and a data row.
The layer is optional and additive. Place <for-table-body> inside a [forTable]; the raw
[forTableRow] / [forTableCell] / [forTableHeaderCell] primitives in
the table README keep working unchanged, and a table that
never imports ForTableBody never bundles it. Split out of the table README in
#1401.
Supported modes: table and grid. Nothing in <for-table-body> is grid-specific: it derives
each stamped cell's role from the table mode and applies no mode guard, so it works under the default
mode="table" and under mode="grid" alike. Choose mode="grid" for interactive cells: roving 2D
keyboard navigation, cell widgets, and cell-entry. Choose mode="table" for read-only or
whole-row navigation lists, where role="grid" would announce an interaction model the list does
not have. See Whole-row navigation lists for the row-interaction hooks
(interactiveRows / rowActivate / rowContextMenu). mode="treegrid" is out of scope: the body
stamps no expansion affordances. The examples below use mode="grid", but each stamps identically under
mode="table" (only the emitted roles change: role="table" with role="cell" cells).
<div forTable mode="grid" ariaLabel="People" selectionMode="multiple">
<for-table-body [rows]="rows()" [rowKey]="rowKey" [sort]="sort()" (sortChange)="sort.set($event)">
<!-- selection column: drop the raw selector primitives into the cell templates -->
<ng-container forTableColumnDef="sel" sticky width="48px">
<ng-template forTableHeaderCellDef
><span forTableSelectAll ariaLabel="Select all"></span
></ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row>
<span forTableRowSelector></span>
</ng-template>
</ng-container>
<ng-container forTableColumnDef="name" sticky sortable resizable resizeAriaLabel="Resize Name">
<ng-template forTableHeaderCellDef>Name</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row let-i="index"
>{{ row.name }}</ng-template
>
</ng-container>
<ng-container forTableColumnDef="status" width="140px">
<ng-template forTableHeaderCellDef>Status</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row
>{{ row.status }}</ng-template
>
</ng-container>
</for-table-body>
</div>
<for-table-body>takes[rows](BYO-data, already sorted / filtered / paged by you), optional[rowKey](row identity used for@fortracking and each row's selection[value]), optional[displayedColumns](which columns render, in order; defaults to declaration order), and[loading]/[placeholderRows](renderforTablePlaceholderCellDefskeletons, or the body-levelforTablePlaceholderCellDefault, for the initial full-replace load; see Interleaved placeholder rows for the infinite-scroll shape that keeps loaded rows and appends trailing skeletons). It ownsgrid-template-columns: each column contributes its[width], falling back to the published--for-table-col-<name>-widthresize var. A resized column therefore drives its own track with no glue.- Auto-wired from per-column flags:
sortablewires[forTableSortHeader](the body derives each header's direction from its[sort]input and re-emits(sortChange)), andresizablewires[forTableColumnResizer](re-emitted through(resizeCommit); giveresizeAriaLabelso the handle is named). Tune the handle per column on[forTableColumnDef]:[resizeMin]/[resizeMax](bounds, drivingaria-valuemin/aria-valuemax),[resizeStep](arrow-key increment),autoFit(double-click size-to-content, on by default; set[autoFit]="false"to disable), andfitIncludesHeader(also account for the header label, isolated with a[forTableColumnLabel]inside the[forTableHeaderCellDef]template). Let the body own width state with[(columnWidths)](see Persisting column widths), or keep applying widths yourself from(resizeCommit). - Consumer-placed in templates: selection (
[forTableRowSelector]/[forTableSelectAll]) and any interactive widget go straight into the cell templates. Row-context primitives resolve their[forTableRow]because the body stamps content with the cell's own injector. - Styling the stamped cells: the body owns the header / data cell elements, so add a class to them
per column with
[headerClass]/[cellClass]on[forTableColumnDef](see Styling the stamped cells below). - Typing
let-row: bind[forTableCellDefRow]to the same array you pass to[rows]. It is read only for type inference, solet-rowis typed as your row type. With a discriminated-union row type, bind[forTableCellDefUnless](and[forTableRowCellDefWhen]on variants) to narrow it further (see Typing a discriminated-union row below).
<for-table-body>'s host is display: contents, so it adds no box between [forTable] and its rows;
all visual styling stays yours off the same data-* / role hooks the raw primitives emit. Full-span
row variants (group headers, separators, summary rows) are covered below via [forTableRowDef], and
drag column reordering via the reorderable flag (see
Column reordering).
Bundle note.
<for-table-body>statically importsforty-cdk/drag-dropso areorderablecolumn can auto-wire drag reordering. Every<for-table-body>consumer therefore bundles drag-drop, even one with no reorderable column. Measured (#1730, productionng buildof an app whose lazy route holds one two-column table): the drag-drop bytes the optimizer retains in that route are 18.0 kB raw / 5.1 kB gzip, and the same route with every columnreorderableis 0.05 kB larger. The directives are reachable fromForTableBody's component definition, so nothing is dropped either way. Put that next to the rest of the layer's cost before reading it as expensive: against the raw path the whole declarative layer is +54.2 kB raw / +13.7 kB transfer on that route, of which drag-drop is a third. Per-entry-point tree-shaking is otherwise intact (a table that never importsForTableBodybundles neither it nor drag-drop). If a simple table is bundle-sensitive and needs no declarative ergonomics, author it from the raw[forTableHeaderCell]/[forTableCell]primitives instead. That path never touches drag-drop.
Styling the stamped cells
Because <for-table-body> stamps the header and data cell elements itself, a consumer cannot put a
class on them from the template. Add one per column with [headerClass] (on the stamped
[forTableHeaderCell]) and [cellClass] (on the stamped [forTableCell] of every data and
placeholder row). Both are static strings applied alongside the cells' existing data-* / role hooks;
leaving them unset adds no class attribute at all.
<ng-container forTableColumnDef="amount" headerClass="num-header" cellClass="num-cell text-right">
<ng-template forTableHeaderCellDef>Amount</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row>{{ row.amount }}</ng-template>
</ng-container>
This is the seam a wrapping design system needs: it can key its stylesheet off classes it owns (a
.num-cell it applies here) instead of scoping CSS to the body's template internals
(for-table-body [forTableCell], role selectors), and it reaches the cell box itself (padding,
truncation, alignment, sticky backgrounds) rather than a wrapper node inside the template. Per-datum
row styling (varying by the row's data, not just the column) is covered by
[rowClass] / [rowAttrs] below.
Persisting column widths ([(columnWidths)])
Instead of maintaining a widths signal, per-column seed / update handlers, and a hand-built
grid-template-columns string, let <for-table-body> own the width state: bind [(columnWidths)]
to a plain map keyed by column name. It seeds each resizable column's handle [width], so the
role="separator" handle exposes aria-valuenow from the first render and the column's track picks up
the seeded width immediately. It also folds every live change (pointer drag, keyboard resize, auto-fit)
back into the map immutably. The map is JSON-serializable, so persisting a user's column layout is one
two-way binding plus one storage write:
@Component({
/* … */
template: `
<div forTable mode="grid" ariaLabel="People">
<for-table-body [rows]="rows()" [rowKey]="rowKey" [(columnWidths)]="widths">
<ng-container
forTableColumnDef="name"
resizable
resizeAriaLabel="Resize Name"
[resizeMin]="80"
[resizeMax]="480"
>
<ng-template forTableHeaderCellDef>Name</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row>{{
row.name
}}</ng-template>
</ng-container>
<!-- … -->
</for-table-body>
</div>
`,
})
export class PeopleTable {
// Seed from storage; write back whenever it changes.
readonly widths = signal<Record<string, number>>(
JSON.parse(localStorage.getItem('people.widths') ?? '{}'),
);
constructor() {
effect(() => localStorage.setItem('people.widths', JSON.stringify(this.widths())));
}
}
Only resizable columns participate; unknown names are ignored. Together with [displayedColumns] and
[sort], [(columnWidths)] makes the full user-configurable table state three bindings. Prefer
(resizeCommit) when you only need the gesture-end event (e.g. to write a single column to a server)
rather than the whole width map.
[width] vs. a resized / seeded width (column track precedence)
<for-table-body> resolves each column's grid-template-columns track as
[width]() ?? var(--for-table-col-<name>-width, [fallbackWidth]() ?? minmax(0, 1fr)), so a static [width] on the def
takes precedence over the published resize var: a seeded or resized width would never reach the
track. A column you resize (or seed through [(columnWidths)]) must therefore leave [width]
unset: it then flexes as minmax(0, 1fr), sharing the free space with the other unsized columns,
until a width is seeded or committed. After that, its track becomes that fixed pixel width and the
remaining 1fr columns re-split what's left. Reserve [width] for columns you never resize (a fixed
48px selection column, an 80px id column); combining it with resizable on the same column pins the
track and makes the handle's width purely advisory (aria-valuenow and (resizeCommit) still fire, but
the column does not visually resize).
[fallbackWidth] — a weighted fluid track before the first resize
minmax(0, 1fr) is a fine default but it is the only track an unsized column could take, so a column
that should fill proportionally and keep a floor had to choose between a fluid track and a resizable
one. [fallbackWidth] supplies the track fragment used as the resize var's fallback instead:
<ng-container
forTableColumnDef="description"
resizable
resizeAriaLabel="Resize description"
fallbackWidth="minmax(120px, 2.5fr)"
>
<ng-template forTableHeaderCellDef>Description</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row
>{{ row.description }}</ng-template
>
</ng-container>
The column renders as minmax(120px, 2.5fr) (2.5× the weight of a plain 1fr sibling, never below
120px) until a width is seeded or committed, at which point --for-table-col-description-width
resolves and the fallback stops applying, exactly as with the default. Unlike [width] it never pins the
column, so the handle keeps driving it.
[fallbackWidth] is ignored when [width] is set (the static track wins before the var is ever
consulted). It is not gated on resizable: a non-resizable column with no [width] resolves through
the same var, which [(columnWidths)] can publish, so a weighted fluid track is equally useful there.
Both inputs are dev-mode-guarded, the same way a column name is. Any open track vocabulary is
accepted (minmax(), fit-content(), calc(), clamp(), var()), but a fragment that would escape the
derived grid-template-columns string throws a [forty-cdk/table] error instead of silently collapsing
the layout: an empty fragment (pass null, or omit the input, to leave the track unset), a ; / { /
} / quote / comment opener, or unbalanced parentheses. That last one is the reason the guard exists at
all for [fallbackWidth]: a stray ) closes the enclosing var( early and swallows every column after
it, which reads as "the whole table lost its layout" rather than "one column has a typo". Production
builds skip the check.
Column reordering (reorderable + columnReorder)
Mark a column reorderable and <for-table-body> makes its header cell a drag-reorder handle. With at
least one reorderable column the body applies [forTableColumnReorder] to the stamped header row and
[forDraggable] (with [dragData] set to the column name) to each reorderable header cell, then
re-emits every committed reorder (pointer drop or keyboard drop) through (columnReorder). Like
sort, reorder is BYO-data: the body never reorders the columns itself. Apply
$event.columns to your own column order and feed it back through [displayedColumns].
<div forTable mode="grid" ariaLabel="People">
<for-table-body
[rows]="rows()"
[displayedColumns]="order()"
(columnReorder)="order.set($event.columns)"
>
<ng-container forTableColumnDef="name" sortable reorderable>
<ng-template forTableHeaderCellDef>Name</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row
>{{ row.name }}</ng-template
>
</ng-container>
<ng-container forTableColumnDef="role" reorderable>
<ng-template forTableHeaderCellDef>Role</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row
>{{ row.role }}</ng-template
>
</ng-container>
<!-- Optional: one shared placeholder for the reordered column's slot during a pointer drag. -->
<ng-template forTableColumnDragPlaceholder>
<div class="col-ghost"></div>
</ng-template>
</for-table-body>
</div>
protected readonly order = signal<readonly string[]>(['name', 'role']);
- Keyboard is inherited, not new: the header row keeps its single composite tab stop,
Spacelifts a header cell for reordering, and Arrow keys move the lifted column (Escapecancels). On a header that is bothsortableandreorderable, the two split along WAI-ARIA lines (Spacelifts andEntertoggles the sort), so a single key never both sorts and reorders. (columnReorder)emits{ from, to, columns }(aTableColumnReorderDescriptor). Itscolumnslists the reorderable columns in their new order, which equals the full displayed order when every displayed column isreorderable. Non-reorderable columns stay static (not draggable) and keep their slots, so a table that mixes them merges the reorderable subset back into its own full order.forTableColumnDragPlaceholderis optional and declared once per body; it is stamped as every reorderable column's pointer-drag placeholder. Omit it to keep drag-drop's default placeholder.- This is the declarative twin of the raw
[forTableColumnReorder]/[forDraggable]composition; it bundlesforty-cdk/drag-dropinto every<for-table-body>: 18.0 kB raw / 5.1 kB gzip retained whether or not a column isreorderable(see the bundle note above).
Virtualized rows
Add [forTableVirtualized] to the same [forTable] element and the body switches to windowed rendering
automatically. It reads the published window off the table context (so forty-cdk/table still never
imports the virtualization core), mounts only the visible slice, sizes its rowgroup to the full scroll
height, and absolutely positions each row. Pass the whole dataset to [rows]: the body derives the
true total from its length, so [rowCount] on [forTable] is unnecessary (bind it only for a
server-known total larger than the loaded rows). There is no #v reference, manual sizer, @for
window, or [virtualIndex] binding. Rows are fixed-size by default, so set the row height in CSS. For
tables that mix row shapes (denser variant rows, group separators), opt in to
measured row heights with measureRows.
The mode="grid" in the example below is a convention, not a requirement of the layer. Windowing is
driven by the <div> structure <for-table-body> always renders, not by the ARIA mode, so
mode="table" windows the same way: the root keeps role="table", stamped cells stay role="cell",
and only the visible slice mounts.
<div
class="scroll-root"
forTable
forTableVirtualized
mode="grid"
ariaLabel="People"
[estimateRowSize]="44"
>
<for-table-body [rows]="rows()" [rowKey]="rowKey">
<ng-container forTableColumnDef="id" width="80px">
<ng-template forTableHeaderCellDef>#</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row>{{ row.id }}</ng-template>
</ng-container>
<ng-container forTableColumnDef="name">
<ng-template forTableHeaderCellDef>Name</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row
>{{ row.name }}</ng-template
>
</ng-container>
</for-table-body>
</div>
.scroll-root {
height: 400px;
overflow: auto;
position: relative;
}
.scroll-root [forTableHeaderRow] {
position: sticky;
top: 0;
}
.scroll-root [forTableRow] {
height: 44px;
}
Measured (variable) row heights
The fixed-size fast path positions every row at estimateRowSize intervals. That is perfect when all rows are
the same height, but a table mixing row shapes (denser variant rows, group separators, summary rows)
would show overlaps or gaps after scroll, because the estimate is wrong for the odd-sized rows. Set
measureRows to opt in to measured heights: the body measures each stamped row after render and feeds
its real height back to the virtualizer, which replaces the estimate and re-aligns the offsets of the
rows below. That keeps the window contiguous no matter how the row heights vary.
<div class="scroll-root" forTable forTableVirtualized mode="grid" ariaLabel="People">
<for-table-body [rows]="rows()" [rowKey]="rowKey" measureRows>
<ng-container forTableColumnDef="name">
<ng-template forTableHeaderCellDef>Name</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row
>{{ row.name }}</ng-template
>
</ng-container>
<ng-container forTableRowDef [when]="isGroupHeader">
<ng-template forTableRowCellDef [forTableRowCellDefRow]="rows()" let-row
>{{ row.group }}</ng-template
>
</ng-container>
</for-table-body>
</div>
estimateRowSize still seeds the initial estimate (keep it close to the common row height for the least
scroll-position shift on first measure). measureRows is off by default and has no effect without
[forTableVirtualized]; a uniform-height table should leave it unset to keep the zero-measurement fast
path. This mirrors the raw [forTableRow] path's v.measureRow(el), which the declarative layer just wires
up for you.
Initial measurement happens once, after a row renders. Ongoing in-place size changes to a row that
stays mounted, such as content that loads asynchronously (images, lazy cells) or a cell that reflows, are picked
up automatically by the virtualizer's own ResizeObserver, which re-measures the row and re-aligns the
rows below without any manual trigger. So a row that grows in place after its data arrives keeps the
window contiguous on its own; you only pass the data through [rows].
Row variants
Declare one or more [forTableRowDef] alongside the columns to render a full-span row for the data it
matches: group headers, section separators, full-width summary or empty-state rows. For each datum the
body picks the first [forTableRowDef] whose [when] predicate returns true and stamps a row whose single
cell spans every column and renders the [forTableRowCellDef] template; unmatched data renders the standard
per-column row. (A [forTableRowDef] can instead carry the placeholderCells flag, with no [forTableRowCellDef], to
stamp per-column skeleton cells rather than a full-span cell; see
Interleaved placeholder rows.) Type let-row by binding
[forTableRowCellDefRow] to the same array you pass to [rows].
For a discriminated-union row type, narrow it with [forTableRowCellDefWhen] / [forTableCellDefUnless] (see
Typing a discriminated-union row).
Variant rows are presentational: the spanning cell carries the row role (gridcell in grid /
treegrid mode), aria-colindex="1", and aria-colspan equal to the column count, but it does not
join the roving 2D navigation grid (arrow keys move between the regular data cells and step over variant
rows), and variant rows are non-selectable. They still occupy a row slot and count towards
aria-rowindex / aria-rowcount (reading order is preserved). Style them off the data-row-variant
hook the spanning cell emits. Row variants compose with [forTableVirtualized]: a matched row inside
the window renders full-span and positioned like any other.
Three requirements when a table mixes row variants with selection or virtualization:
rowKeymust return a defined, unique key for variant data too. The body tracks each rendered row by itsrowKeyidentity, falling back to the dataset index only whenrowKeyis unset or returnsundefined. A variant datum that yieldsundefinedtherefore tracks by index, which can collide with a numeric identity from a regular row and trip Angular'sNG0955duplicate-track-key error. Give group-header / separator data their own stable keys. The simplest scheme is a negative-id namespace reserved for variant data, disjoint from the positive ids the real rows carry (see thetsblock below).- Exclude variant-matched data from
[selectableValues]. The total-aware select-all pattern passes the whole dataset as[selectableValues]. Variant rows are non-selectable, so leaving their data in makes them phantom selectable values: the select-all tri-state never reaches'all'and[(value)]accumulates values no row reflects. Filter them out with the same predicate the[forTableRowDef]matches on (e.g.rows().filter((r) => !isGroupHeader(r))). - Keep the
[forTableRowCellDef]template presentational. Its content spans the row but stays out of the grid's single tab stop, so it must contain no interactive content (buttons, links, form controls, all of which become keyboard-unreachable) and no[forTableCell](it would register a cell handle on the variant row and make the roving grid ragged).
<div forTable mode="grid" ariaLabel="Grouped people">
<for-table-body [rows]="rows()" [rowKey]="rowKey">
<ng-container forTableColumnDef="name">
<ng-template forTableHeaderCellDef>Name</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row
>{{ row.name }}</ng-template
>
</ng-container>
<ng-container forTableColumnDef="role">
<ng-template forTableHeaderCellDef>Role</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row
>{{ row.role }}</ng-template
>
</ng-container>
<ng-container forTableRowDef [when]="isGroupHeader">
<ng-template forTableRowCellDef [forTableRowCellDefRow]="rows()" let-row
>{{ row.group }}</ng-template
>
</ng-container>
</for-table-body>
</div>
interface Row {
id: number;
name?: string;
group?: string;
header?: boolean;
}
protected readonly isGroupHeader = (row: Row): boolean => row.header === true;
// Group-header data carry negative ids, disjoint from the real rows' positive ids,
// so every datum — variant or not — has a defined, unique tracking key.
protected readonly rowKey = (row: Row): number => row.id;
// Total-aware select-all excludes the non-selectable variant rows.
protected readonly selectableIds = computed(() =>
this.rows()
.filter((r) => !this.isGroupHeader(r))
.map((r) => r.id),
);
[data-row-variant] {
grid-column: 1 / -1; /* already applied inline; restate only to layer your own styles */
font-weight: 600;
background: var(--group-header-bg);
}
Interleaved placeholder rows
[loading] is the full-replace skeleton: it swaps the whole dataset for [placeholderRows]
skeleton rows built from each column's [forTablePlaceholderCellDef]. That is the right shape for the initial load,
when there are no rows yet.
Paginated / infinite-scroll tables load differently: they keep the rows already loaded and show a few
trailing (or interleaved) skeleton rows while the next page fetches. Model that with a
placeholderCells row variant, a [forTableRowDef] that matches your placeholder data and
stamps one skeleton cell per column from the same [forTablePlaceholderCellDef] templates, in place among the
real rows:
- The matched rows are non-selectable, and their cells are stamped disabled. Grid-mode arrow navigation therefore steps over them while the roving grid stays rectangular (one cell per column, unlike a full-span variant).
- A column that omits
[forTablePlaceholderCellDef]falls back to the body-level[forTablePlaceholderCellDefault], then to an empty cell. That way you mark only the columns whose skeleton shape differs from the shared one (a circle for an avatar column, a bar for text). - It composes with
[forTableVirtualized]for free: placeholder rows are ordinary data, so they count in the total and get windowed and positioned like any row.
A [forTableRowDef] must declare exactly one of a [forTableRowCellDef] template (full-span variant) or the
placeholderCells flag; declaring both or neither throws a [forty-cdk/table] error. [loading] /
[placeholderRows] stay unchanged as the sugar for the initial full-replace state.
<for-table-body [rows]="rows()" [rowKey]="rowKey">
<ng-container forTableColumnDef="avatar" width="48px">
<ng-template forTableHeaderCellDef></ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row>
<img [src]="row.avatar" alt="" />
</ng-template>
<!-- circle skeleton for the avatar column -->
<ng-template forTablePlaceholderCellDef
><span class="skeleton skeleton--circle"></span
></ng-template>
</ng-container>
<ng-container forTableColumnDef="name">
<ng-template forTableHeaderCellDef>Name</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row>{{ row.name }}</ng-template>
<!-- bar skeleton for the text column -->
<ng-template forTablePlaceholderCellDef
><span class="skeleton skeleton--bar"></span
></ng-template>
</ng-container>
<!-- trailing skeleton rows appended to rows() while the next page loads -->
<ng-container forTableRowDef [when]="isPlaceholder" placeholderCells />
</for-table-body>
interface Row {
id: number;
name?: string;
avatar?: string;
pending?: boolean;
}
// Match the placeholder rows you appended to rows() while fetching the next page.
protected readonly isPlaceholder = (row: Row): boolean => row.pending === true;
// A placeholder datum still needs a defined, unique rowKey (a negative-id namespace, say),
// exactly like a full-span variant — see the Row variants requirements above.
protected readonly rowKey = (row: Row): number => row.id;
Shared skeleton: [forTablePlaceholderCellDefault]
Most columns of a table share one skeleton shape, and repeating the same [forTablePlaceholderCellDef] in every
def is duplication for what is a table-level concern. Declare it once per body on an
<ng-template forTablePlaceholderCellDefault> among the column defs; every displayed column that declares no
[forTablePlaceholderCellDef] of its own stamps it instead.
Each cell resolves its placeholder in three steps, identically in both stamping paths ([loading]
rows and placeholderCells variant rows):
- the column's own
[forTablePlaceholderCellDef], if it has one; - else the body-level
[forTablePlaceholderCellDefault], if declared; - else an empty cell.
So the default never overrides a column that opted into its own shape, and a table with neither template keeps the empty cell it rendered before. The default takes no template context, exactly like the per-column template.
<for-table-body [rows]="rows()" [rowKey]="rowKey" [loading]="loading()">
<!-- the shape 6 of these 7 columns share -->
<ng-template forTablePlaceholderCellDefault
><span class="skeleton skeleton--bar"></span
></ng-template>
<ng-container forTableColumnDef="avatar" width="48px">
<ng-template forTableHeaderCellDef></ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row
><img [src]="row.avatar" alt=""
/></ng-template>
<!-- this one column overrides it -->
<ng-template forTablePlaceholderCellDef
><span class="skeleton skeleton--circle"></span
></ng-template>
</ng-container>
<ng-container forTableColumnDef="name">
<ng-template forTableHeaderCellDef>Name</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row>{{ row.name }}</ng-template>
</ng-container>
</for-table-body>
Typing a discriminated-union row
When rows are a discriminated union whose variant members render through a [forTableRowDef], let-row
would otherwise type as the full union in every template. A per-column [forTableCellDef] only ever
receives the non-variant members, and a [forTableRowCellDef] only ever receives its matched variant. Bind
the same type guard you use on the def's [when] to the compiler-only inference inputs so each
let-row is narrowed to exactly what it receives:
[forTableCellDefUnless]on a[forTableCellDef]narrowslet-rowtoExclude<Row, V>, the members not rendered as a variant. Compose several variants into one union guard.[forTableRowCellDefWhen]on a[forTableRowCellDef]narrowslet-rowto the matched variantV.
Both are read only by the compiler, exactly like [forTableCellDefRow] / [forTableRowCellDefRow]; omitting them
leaves let-row as the full row type (no behavioural or type change for existing tables). This
replaces the filtered-computed-per-template workaround (dataRows() / separatorRows() copies of
rows() kept only to satisfy the compiler). Bind rows() directly and let the guard narrow.
interface DataRow {
kind: 'data';
name: string;
amount: number;
}
interface SeparatorRow {
kind: 'separator';
label: string;
}
type Row = DataRow | SeparatorRow;
protected readonly isSeparator = (row: Row): row is SeparatorRow => row.kind === 'separator';
<for-table-body [rows]="rows()">
<ng-container forTableColumnDef="name">
<ng-template forTableHeaderCellDef>Name</ng-template>
<!-- row: DataRow -->
<ng-template
forTableCellDef
[forTableCellDefRow]="rows()"
[forTableCellDefUnless]="isSeparator"
let-row
>
{{ row.name }} — {{ row.amount }}
</ng-template>
</ng-container>
<ng-container forTableRowDef [when]="isSeparator">
<!-- row: SeparatorRow -->
<ng-template
forTableRowCellDef
[forTableRowCellDefRow]="rows()"
[forTableRowCellDefWhen]="isSeparator"
let-row
>
{{ row.label }}
</ng-template>
</ng-container>
</for-table-body>
Wrapping the declarative body
A design system layered on forty eventually wants to hide the low-level defs behind its own
authoring shapes: a preset column component collapsing a column's header / data / placeholder
templates into one line, and a scaffold wrapper table that bakes in the [forTable] root,
virtualization wiring and shared row defs so a consumer only declares columns. Both work, because
<for-table-body> does not content-query its building blocks: each [forTableColumnDef],
[forTableRowDef], [forTableColumnDragPlaceholder] and [forTablePlaceholderCellDefault] registers itself
with the surrounding def registry through DI at construction (and unregisters when destroyed).
Registered defs are exposed in document order, so a def that constructs late (one declared in a
preset's view, one mounted by @if) still renders in its authored place, and [displayedColumns]
still pins an explicit order on top. A def with no reachable registry throws a [forty-cdk/table]
error instead of being silently inert.
Preset column component
Element DI follows the declaration tree, so a preset host declared inside the body's tags lets the def in the preset's own view resolve the body's registry. No providers, no registration code:
import { booleanAttribute, ChangeDetectionStrategy, Component, input } from '@angular/core';
import { ForTableColumnDef, ForTableCellDef, ForTableHeaderCellDef } from 'forty-cdk/table';
@Component({
selector: 'my-text-column',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ForTableColumnDef, ForTableHeaderCellDef, ForTableCellDef],
template: `
<ng-container [forTableColumnDef]="name()" [sortable]="sortable()" [width]="width()">
<ng-template forTableHeaderCellDef>{{ header() }}</ng-template>
<ng-template forTableCellDef let-row>{{ value()(row) }}</ng-template>
</ng-container>
`,
})
export class MyTextColumn<T> {
readonly name = input.required<string>();
readonly header = input.required<string>();
readonly value = input.required<(row: T) => unknown>();
readonly sortable = input(false, { transform: booleanAttribute });
readonly width = input<string | null>(null);
}
<div forTable mode="grid" ariaLabel="People">
<for-table-body [rows]="rows()" [rowKey]="rowKey">
<my-text-column name="code" header="Code" [value]="pickCode" width="8rem" />
<my-text-column name="name" header="Name" [value]="pickName" sortable />
</for-table-body>
</div>
Scaffold wrapper table
Defs a consumer projects through the wrapper's <ng-content> are content of the wrapper, not of
the <for-table-body> inside the wrapper's template. Their declaration ancestors are the wrapper's
host, so they never see the body's own registry. Provide one on the wrapper with
provideForTableDefRegistry() and hand it to the inner body through [defs]:
import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
import {
FOR_TABLE_DEF_REGISTRY,
ForTable,
ForTableBody,
provideForTableDefRegistry,
} from 'forty-cdk/table';
@Component({
selector: 'my-data-table',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: provideForTableDefRegistry(),
imports: [ForTable, ForTableBody],
template: `
<div forTable mode="grid" [ariaLabel]="ariaLabel()">
<for-table-body [rows]="rows()" [rowKey]="rowKey()" [defs]="defs">
<ng-content />
</for-table-body>
</div>
`,
})
export class MyDataTable<T> {
readonly rows = input.required<readonly T[]>();
readonly rowKey = input<(row: T, index: number) => unknown>();
readonly ariaLabel = input.required<string>();
protected readonly defs = inject(FOR_TABLE_DEF_REGISTRY);
}
<my-data-table [rows]="rows()" [rowKey]="rowKey" ariaLabel="People">
<ng-container forTableColumnDef="name" sortable>
<ng-template forTableHeaderCellDef>Name</ng-template>
<ng-template forTableCellDef [forTableCellDefRow]="rows()" let-row>{{ row.name }}</ng-template>
</ng-container>
</my-data-table>
Three rules for the scaffold shape:
- A bound
[defs]replaces the body's own registry. Defs the wrapper declares inside the<for-table-body>tags would register with the body instead and be ignored, so the body throws rather than dropping them. Declare the wrapper's own baked-in defs (a shared placeholder row def, a fixed actions column) next to the projected ones (anywhere in the wrapper's template outside the<for-table-body>element), where they reach the same registry and interleave with the projected defs by document order. FOR_TABLE_DEF_REGISTRYis a read token.ForTableDefRegistryexposescolumnNames(every registered column'snamein document order, which is useful to derive the wrapper's own[displayedColumns]); the registration protocol behind it is internal, so only the registryprovideForTableDefRegistry()installs is accepted by[defs].- Compose the body, don't subclass it. A component subclass replaces its base's
providerswholesale, which strips the registry the defs resolve. The body then throws a[forty-cdk/table]error namingprovideForTableDefRegistry()rather than a bareNG0201naming a class you cannot import. Spreading the helper in is not the fix, though: a subclass inherits neithertemplatenorimportseither, so it constructs and then renders none of the body. Composition is the whole wrapping story here.