forty-cdk
llms.txt

Primitives

Table

A headless data table that decorates a native <table> or a <div> CSS grid with WAI-ARIA table / grid semantics: sticky headers, 2D keyboard navigation, row selection, sortable headers, column resizing and column / row reordering.

forty-cdk/table WAI-ARIA APG

Move around the grid with the arrow keys. One tab stop serves the whole table, Ctrl+Home / Ctrl+End jump to its corners, and the focused cell carries data-highlighted.

Name
Role
Department
Location
Ada Lovelace
Engineer
Platform
London
Alan Turing
Researcher
Research
Manchester
Grace Hopper
Engineer
Compilers
New York
Katherine Johnson
Analyst
Aerospace
Hampton
Edsger Dijkstra
Researcher
Research
Rotterdam
Barbara Liskov
Professor
Research
Boston
Margaret Hamilton
Engineer
Aerospace
Boston
Tim Berners-Lee
Engineer
Platform
London
Donald Knuth
Professor
Compilers
Stanford

The library sets roles, aria-label, writing direction, data-column, sticky hooks, and (in grid mode) aria-rowcount / aria-colcount / aria-rowindex / aria-colindex and roving keyboard navigation. The consumer owns all styles.

Anatomy

<div forTable mode="grid" ariaLabel="People" selectionMode="multiple">
  <div role="rowgroup">
    <div forTableHeaderRow>
      <div forTableHeaderCell name="sel">
        <span forTableSelectAll ariaLabel="Select all rows"></span>
      </div>
      <div forTableHeaderCell name="name" forTableSortHeader column="name">
        Name
        <button forTableColumnResizer column="name" aria-label="Resize Name column"></button>
      </div>
    </div>
  </div>
  <div role="rowgroup">
    <!-- one [forTableRow] per data row -->
    <div forTableRow [value]="row.id">
      <div forTableCell name="sel"><span forTableRowSelector></span></div>
      <div forTableCell name="name">{{ row.name }}</div>
    </div>
  </div>
</div>

Opt-in companions compose on the same elements: [forTableVirtualized] on [forTable] for windowed rows (its own forty-cdk/table-virtualization entry point), and [forTableColumnReorder] / [forTableRowReorder] on the header row / data rowgroup for drag reordering.

Examples

Multiple row selection

selectionMode="multiple" adds row selection. The row owns aria-selected; [forTableRowSelector] is a decorative per-row affordance and [forTableSelectAll] is a tri-state header checkbox. Click a row, the selector, or press Space on a focused cell.

Name
Role
Department
Ada Lovelace
Engineer
Platform
Alan Turing
Researcher
Research
Grace Hopper
Engineer
Compilers
Katherine Johnson
Analyst
Aerospace
Edsger Dijkstra
Researcher
Research
Barbara Liskov
Professor
Research
Margaret Hamilton
Engineer
Aerospace
Tim Berners-Lee
Engineer
Platform
Donald Knuth
Professor
Compilers

Single-column sort

Click a header, or tab to it and press Enter or Space, and its [forTableSortHeader] cycles the column through ascending, descending and unsorted. Sorting another column clears the first, because the demo holds a single sort descriptor and reorders its own rows from it. Sortable headers covers the options on the direction cycle and the grid-mode tab stop.

Name Role Department Location
Ada Lovelace Engineer Platform London
Alan Turing Researcher Research Manchester
Grace Hopper Engineer Compilers New York
Katherine Johnson Analyst Aerospace Hampton
Edsger Dijkstra Researcher Research Rotterdam
Barbara Liskov Professor Research Boston
Margaret Hamilton Engineer Aerospace Boston
Tim Berners-Lee Engineer Platform London
Donald Knuth Professor Compilers Stanford

Resizable columns

Drag a header's [forTableColumnResizer] handle to resize its column, or focus a header cell, press Enter to reach its handle and step the width by 10px with ArrowLeft / ArrowRight. Each column stops at its own [min] / [max]. The directive only publishes the width; Column resizing shows the grid-template-columns wiring, the commit output and auto-fit.

Name
Role
Department
Location
Ada Lovelace
Engineer
Platform
London
Alan Turing
Researcher
Research
Manchester
Grace Hopper
Engineer
Compilers
New York
Katherine Johnson
Analyst
Aerospace
Hampton
Edsger Dijkstra
Researcher
Research
Rotterdam
Barbara Liskov
Professor
Research
Boston
Margaret Hamilton
Engineer
Aerospace
Boston
Tim Berners-Lee
Engineer
Platform
London
Donald Knuth
Professor
Compilers
Stanford

Reorderable columns and rows

The companion directives [forTableColumnReorder] (on the header row) and [forTableRowReorder] (on the rowgroup) wrap the drag-drop primitive. Add [forDraggable] [dragData] to each header cell / row, then drag to reorder. aria-rowindex / aria-colindex recompute automatically. The library never mutates your data: the handlers apply the move to local signals.

Name
Role
Department
Location
Ada Lovelace
Engineer
Platform
London
Alan Turing
Researcher
Research
Manchester
Grace Hopper
Engineer
Compilers
New York
Katherine Johnson
Analyst
Aerospace
Hampton
Edsger Dijkstra
Researcher
Research
Rotterdam
Barbara Liskov
Professor
Research
Boston

Virtualized rows (10,000)

[forTableVirtualized] sits on the same element as [forTable] in <div> grid mode. Set [rowCount] to the true total. It drives both aria-rowcount and the window size. Each [forTableRow] gets [virtualIndex] and a translateY transform. Roving 2D keyboard navigation works across the full 10,000 rows, scrolling out-of-window cells into view on demand.

Name
Role
Department
Location

Infinite scroll

The headless injectInfiniteScroll core composes on top of [forTableVirtualized]: derive a [first, last+1) range from virtualRows() and feed it the loaded count. The detector fires once per threshold crossing, suppresses re-fire while the load promise is pending and re-arms when [rowCount] grows after each appended page, up to a cap.

Name
Role
Department
Location

Loaded 50 of 1000 — idle

Everything at once

One grid-mode table composing six features on the same element: multiple row selection with a tri-state select-all, sortable headers, column resizing, column reordering, virtualization and infinite scroll. [selectableValues] feeds select-all the full loaded dataset so its tri-state stays correct beyond the rendered window.

Name
Role
Department
Location

0 selected · loaded 150 of 600 · idle

API

ForTable

PropertyTypeDescription
mode
'table' | 'grid' | 'treegrid'
ARIA role emitted on the host.
Default: 'table'
ariaLabel
string | null
Reactive accessible label.
Default: null
dir
'ltr' | 'rtl' | null
Writing direction; resolves ambient when unset.
Default: null
rowCount
number
True total data-row count for aria-rowcount. Optional with <for-table-body> (its dataset length is used); bind it only for a server-known total larger than the loaded rows. It also sizes the virtualized scroll range unless [forTableVirtualized] narrows that with [virtualRowCount]. Ignored in table mode.
Default: body dataset length, else -1 when virtualized, else rendered count (plus the header offset)
colCount
number
True total column count for aria-colcount. Ignored in table mode.
Default: rendered count, else -1
selectionMode
'none' | 'single' | 'multiple'
Row selection mode.
Default: 'none'
selectionBehavior
'toggle' | 'replace' | 'none'
How a row click mutates selection (modifier-aware in replace mode; 'none' leaves it to the selector / select-all / Space).
Default: 'toggle'
value
model
Two-way bindable selected row values. Infers the row-value type T.
Default: []
compareWith
(a: T, b: T) => boolean
Equality comparator for row values. Override for object rows.
Default: ===
selectableValues
readonly T[] | null
Full ordered set of selectable values for total-aware aggregates under virtualization; null uses the rendered rows.
Default: null
expanded
model
Two-way bindable open parent-row values for mode="treegrid". Ignored in other modes.
Default: []
cellActivate
output
grid / treegrid only: fires on Enter on a focused data cell holding no widget, with the row's [value] (undefined when it has none), the column name and the event. See Activating a cell.

ForTableHeaderCell

PropertyTypeDescription
name
string (required)
Column identifier, reflected as data-column.
Default: —
sticky
boolean | 'end'
Sticky edge; reflected as data-sticky.
Default: false

ForTableCell

PropertyTypeDescription
name
string (required)
Column identifier, reflected as data-column.
Default: —
sticky
boolean | 'end'
Sticky edge; reflected as data-sticky.
Default: false
disabled
boolean
Skipped during navigation; reflects aria-disabled / data-disabled.
Default: false

ForTableRow

PropertyTypeDescription
value
unknown
Selection identity for this row. Leave unset for non-selectable rows.
Default: undefined
level
number
1-based tree depth for aria-level in mode="treegrid". Ignored in other modes.
Default: 1
expandable
boolean
Marks this row as an expandable parent; emits aria-expanded + data-state.
Default: false
activate
output
grid / treegrid only: fires on a row click outside an interactive descendant and on the Enter the root emits as cellActivate, with the row's [value] and the event. See Activating a cell.

ForTableSelectAll

PropertyTypeDescription
ariaLabel
string | null
Accessible label for the select-all checkbox (e.g. "Select all rows").
Default: null
state
'none' | 'some' | 'all' | null
Externally owned tri-state. When bound, aria-checked / data-state follow it and an activation emits toggleAll instead of writing the table's [(value)].
Default: null
toggleAll
output
Fires on a click, Space or Enter while [state] is bound.

ForTableSortHeader

PropertyTypeDescription
column
string (required)
Column identity included in the sortChange payload.
Default: —
direction
'ascending' | 'descending' | 'none'
Current sort direction (two-way bindable via [(direction)]).
Default: 'none'
disableClear
boolean
Skip the 'none' step: cycle becomes ascending ↔ descending.
Default: false
firstClickDirection
'ascending' | 'descending'
Direction a previously-unsorted column enters on its first activation (the 'none' → ? step).
Default: 'ascending'
sortable
boolean
When false, the header is fully inert (no tabindex, no aria-sort).
Default: true

ForTableColumnResizer

PropertyTypeDescription
column
string (required)
Column identity; included in the resizeCommit payload and the CSS var name.
Default: —
width
model
Current column width in pixels. Two-way bindable via [(width)]. Fires widthChange on every live update.
Default: —
min
number
Minimum width in pixels.
Default: 0
max
number
Maximum width in pixels. No upper bound by default.
Default: Infinity
step
number
Pixels applied per ArrowLeft / ArrowRight press.
Default: 10
autoFit
boolean
Opt-in: dblclick on the handle fits the column to its content width via fitToContent(). No behaviour change when unset.
Default: false
fitIncludesHeader
boolean
Opt-in: auto-fit also accounts for the header label (marked with a sibling [forTableColumnLabel]), fitting to max(header label, …data cells). Degrades to data-cells-only with no marker present.
Default: false
widthRevert
((descriptor: TableResizeDescriptor) => void) | undefined
Teardown-only revert callback, bound as a function reference. Called with the pre-drag width when the handle is destroyed mid-drag, where [(width)] can no longer emit. Silent on the Escape / pointercancel reverts.
Default: —

Data attributes

Token / attributeEmitted byValues / description
--for-table-header-height[forTable]Header row height in px. Updated on resize.
data-mode[forTable]'table' | 'grid' | 'treegrid'
data-columnheader / data cellColumn name from the name input.
data-stickyheader / data cell'' (start-edge) or 'end' when sticky; absent otherwise.
data-highlightedheader / data cellPresent on the currently roving-focused cell in grid / treegrid mode.
aria-expanded[forTableRow]"true" / "false" (always-emit) on expandable rows in treegrid mode; absent on leaves.
data-state[forTableRow]"open" / "closed" on expandable rows in treegrid mode; absent on leaves.
aria-level[forTableRow]1-based depth in treegrid mode; absent otherwise.
aria-posinset[forTableRow]1-based position among same-level siblings in treegrid mode; absent otherwise.
aria-setsize[forTableRow]Total same-level sibling count in treegrid mode; absent otherwise.
aria-rowindex[forTableHeaderRow]"1" in grid / treegrid mode (the header is the grid's first row); absent in table mode.
aria-rowindex[forTableRow]1-based row index counting the header row (first data row is 2). Absent in table mode.
aria-colindexheader / data cell1-based column index within the row. Absent in table mode.
aria-colindex[forTableVariantCell]Always "1", because a full-span row's only cell starts at the first column. Emitted in every mode, matching what <for-table-body> stamps.
aria-colspan[forTableVariantCell]The grid's rendered column count. Absent while no cell has registered one (an empty virtualized window with no header row).
data-row-variant[forTableVariantCell] / <for-table-body>Present ("") on the full-span cell of a presentational row. The hook to span it in CSS.
aria-selected[forTableRow]"true" / "false" (always-emit) on selectable rows (with a [value]) when selectionMode is not 'none'; absent on rows without a [value].
data-selected[forTableRow]Present ("") when selected; absent when not. Boolean present/absent hook.
aria-multiselectable[forTable]"true" when selectionMode="multiple" in grid / treegrid mode; absent otherwise (including table mode, where role="table" forbids it).
aria-checked[forTableRowSelector]"true" / "false" (always-emit) reflecting the row's selection. The selector is role="checkbox"; the enclosing row still owns aria-selected.
tabindex[forTableRowSelector]"0" in table mode (focusable keyboard selection path); "-1" in grid / treegrid mode (yields to the roving grid).
data-state[forTableRowSelector]"checked" or "unchecked". Styling hook alongside aria-checked.
aria-checked[forTableSelectAll]"true" / "false" / "mixed" (tri-state).
data-state[forTableSelectAll]"checked" / "unchecked" / "indeterminate".
aria-sort[forTableSortHeader]"ascending" or "descending" while sorted; absent (null) when unsorted. Truthy-only.
data-sorted[forTableSortHeader]A CSS styling hook (e.g. for a sort arrow glyph) carrying the same value as aria-sort.
data-sortable[forTableSortHeader]Present ("") while sortable; absent otherwise. Styling hook, and the marker that makes Enter sort (not enter) a grid header cell.
--for-table-col-<name>-width[forTable] (set by [forTableColumnResizer])Resolved column width in px; apply it to your layout.
data-resizing[forTableColumnResizer]Present ("") while a pointer drag is active.

Keyboard

Two regimes, chosen by mode. The default mode="table" adds no navigation of its own: every focusable piece keeps its own tab stop, reached with Tab and activated with Enter / Space. mode="grid" and mode="treegrid" replace those stops with one composite roving tab stop over the header and the data cells (Tab reaches the whole grid once), and every key below fires on the focused cell. All horizontal keys are mirrored when the resolved writing direction is rtl, and cells marked disabled are skipped by navigation.

Static tab stops

mode="table". No roving group and no cell entry: the table is a sequence of ordinary tab stops.

KeyAction
TabMove to the next focusable piece: a sortable [forTableSortHeader], a [forTableSelectAll], each [forTableRowSelector], each [forTableColumnResizer], the one roving tab stop a [forTableRowReorder] gives its draggable rows, and, with interactiveRows on <for-table-body>, each data row.
Enter / SpaceActivate the focused piece: cycle the sort on a sortable header, toggle the row on a [forTableRowSelector], toggle the tri-state on a [forTableSelectAll], lift the roving [forDraggable] row of a [forTableRowReorder].
EnterOn a data row interactiveRows made a tab stop, emit rowActivate. A press originating from an interactive descendant runs that control instead and emits nothing.

Cell navigation

grid / treegrid. The header row is the grid's first row, so the arrows cross between it and the body.

KeyAction
TabEnter and leave the whole grid in one stop. Inside an entered cell it cycles that cell's widgets instead, and while a row or column is lifted it cancels the drag.
ArrowRight / ArrowLeftNext / previous cell in the current row. On an expandable treegrid row they expand / collapse first (see Selection and expansion).
ArrowDown / ArrowUpThe cell one row down / up, keeping the column. ArrowUp from the first data row crosses into the header cell of the same column.
Home / EndFirst / last cell of the current row.
Ctrl/Cmd+Home / Ctrl/Cmd+EndFirst / last cell of the whole grid. Ctrl/Cmd+Home lands on the first header cell whenever the header joins the grid, and one ArrowDown moves into the first data cell.
PageUp / PageDownOne screenful of rows up / down, keeping the column. A page is the rendered row count, so a virtualized grid pages by its visible window. Neither jumps to the grid ends.

PageUp from within the first screenful of data rows clamps to the header row, for the same reason ArrowUp crosses into it. Under [forTableVirtualized] a move resolving a row outside the rendered window scrolls that row into view and lands focus on the target cell once it mounts; a move onto the header row also scrolls the window back to row 0, so the grid is never left focused on its header while the window sits at the end of the dataset. When the header does not join the grid (an incomplete header row), Ctrl/Cmd+Home lands on the first data cell instead and ArrowUp / PageUp stop there.

Cell entry

grid / treegrid. The APG cell-entry mode that reaches a widget rendered inside a cell.

KeyAction
F2Move focus into the focused cell's first focusable widget. No-op on a cell holding none, and never emits cellActivate.
EnterThe same entry, on a cell whose keys no other affordance owns. Sorting, resizing and reordering covers the two that do. On a data cell holding no widget it emits cellActivate instead, and rowActivate on a <for-table-body> with interactiveRows (see Activating a cell).
Tab / Shift+TabWhile inside an entered cell: move between that cell's widgets, wrapping at both ends. Focus cannot leave the cell for another cell or the next document tab stop.
EscapeWhile inside an entered cell: return focus to the owning cell and leave interaction mode.

The cycle reaches every focusable in the cell, tabindex="-1" included, so a header cell holding a column-menu button and a [forTableColumnResizer] resize handle is fully keyboard-operable. "Focusable" is the same set Enter / F2 enters: a natively-focusable element still counts while grid mode holds it at tabindex="-1", but an element focusable only because you gave it a tabindex does not. A <span forTableSelectAll> is therefore reachable in mode="table" and not in a grid; put it on a <button type="button"> to keep it in the cycle. While focus is inside a cell's widget, Arrow keys act on the widget rather than on the grid, and anything else that moves focus out of the cell ends interaction mode too. A cell holding one widget wraps back to that widget, so Tab there moves nothing and Escape is the only way out. This is a deliberate reading of the APG grid pattern, whose Tab "may wrap inside a single cell", applied uniformly rather than only to cells that happen to hold two.

Selection and expansion

KeyAction
SpaceOn a focused data cell in grid / treegrid with a selectionMode other than 'none': toggle the enclosing row's selection and prevent the page scroll. The row needs a [value], and a Space originating from a nested element is ignored.
ArrowRighttreegrid only: expand the focused collapsed parent row (RTL: collapse). On a leaf, or a row already in that state, it falls through to cell navigation.
ArrowLefttreegrid only: collapse the focused expanded parent row (RTL: expand). Otherwise it navigates.
ContextMenuWith interactiveRows, emits rowContextMenu on the row it fires over, in every mode, as the keyboard half of the right-click. Unguarded, so it still offers the row's menu over an inner control.

Sorting, resizing and reordering

Three affordances contend for Enter and Space on a header cell, and the split follows WAI-ARIA lines so a single press never both sorts and lifts.

KeyAction
Enter / SpaceOn a sortable header cell with no [forDraggable]: cycle the sort direction, keeping focus on the cell.
EnterOn a header cell that is both sortable and draggable: cycle the sort. F2 still enters the cell.
SpaceOn a header cell that is both sortable and draggable: lift the column. On one pinned with [dragDisabled] there is no lift to collide with, so it sorts on both keys.
Enter / SpaceOn a draggable header cell that is not sortable: lift the column.
Ctrl+Space / Cmd+SpaceOn any data cell inside a [forTableRowReorder] in grid / treegrid: lift the enclosing row. The plain Space stays selection and idle arrows stay grid navigation.
ArrowLeft / ArrowRightOn a focused [forTableColumnResizer]: resize the column by [step] pixels, clamped to [min] / [max], emitting one resizeCommit per press. RTL-mirrored.
EscapeDuring a pointer resize drag: restore the pre-drag width and emit no resizeCommit.

While a column is lifted, ArrowLeft / ArrowRight move it one position (RTL-mirrored), Home / End move it to the first / last position, Enter / Space drop it and Escape / Tab cancel. While a row is lifted, ArrowUp / ArrowDown move the target one row, Home / End move it to the first / last row of the dataset, PageUp / PageDown move by one rendered window under [forTableVirtualized] (and to the first / last row without it), Enter / Space drop and Escape / Tab cancel. After a row drop, focus follows the row to its new place, back onto the cell (or row) it was lifted from. Focus leaving the reorder container cancels an in-flight lift too.

Accessibility

Implements the WAI-ARIA Table pattern and the WAI-ARIA Grid pattern.

  • Label the table via the reactive [ariaLabel] input or a native aria-labelledby pointing at a visible caption / heading.
  • mode="table" sets role="table" with semantic role="columnheader" / role="cell" cells. Screen readers announce row and column counts from native semantics.
  • mode="grid" sets role="grid" with role="gridcell" cells. The root emits aria-rowcount / aria-colcount; the header row and every data row emit aria-rowindex (the header row is 1, so data rows start at 2 and aria-rowcount counts the header); header and data cells emit aria-colindex. Header and body share one composite roving tab stop, whose full keymap is collected under Keyboard. Override [rowCount] / [colCount] for server-paged or virtualized datasets so screen readers announce correct totals.
  • mode="treegrid" sets role="treegrid". Expandable rows emit aria-expanded="true"|"false" and aria-level / aria-posinset / aria-setsize; leaf rows emit none of these, matching APG "end nodes lack aria-expanded".
  • Row selection (selectionMode not 'none'): each selectable row (one with a [value]) emits aria-selected="true"|"false"; rows without a [value] (full-span variant rows) are non-selectable and emit no aria-selected; in grid / treegrid mode 'multiple' adds aria-multiselectable="true" on the root (never in table mode, where role="table" forbids it). [forTableSelectAll] emits aria-checked in tri-state.
  • Full-span rows (group separators, section headers, summaries) use [forTableVariantCell], which emits aria-colindex="1" and an aria-colspan over the grid's columns and registers no cell handle. Arrow navigation steps over the row onto the next data row, and the grid's column count and header participation are unaffected by it. The failure a hand-written [forTableCell] produces instead is silent and only surfaces from the keyboard.
  • Sortable headers emit aria-sort="ascending"|"descending" while sorted; the attribute is absent (not "none") when unsorted, per APG.
  • Column resizers must be focusable elements with an aria-label naming the column (e.g. aria-label="Resize Name column").
  • Disabled cells use aria-disabled="true" + data-disabled; they are skipped during grid navigation but remain focusable, consistent with the APG disabled pattern.
  • All horizontal keyboard navigation is RTL-mirrored when the resolved writing direction is rtl.

Styling

forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes and CSS custom properties listed under Data attributes, not off the for* selectors (Styling forty-cdk explains why).

Native <table> mode

<table forTable [ariaLabel]="caption">
  <thead>
    <tr forTableHeaderRow>
      <th forTableHeaderCell name="name" sticky>Name</th>
      <th forTableHeaderCell name="role">Role</th>
    </tr>
  </thead>
  <tbody>
    <tr forTableRow>
      <td forTableCell name="name">Ada Lovelace</td>
      <td forTableCell name="role">Engineer</td>
    </tr>
  </tbody>
</table>

<div> mode

When you need virtual scrolling, use <div role> structure with mode="grid" on the root. The <div> mode is the only shape supported by virtualizers because native <table> cannot have its rows omitted from the DOM mid-body. All pieces accept any element.

<div
  forTable
  mode="grid"
  ariaLabel="People"
  style="display: grid; grid-template-columns: 1fr 1fr; overflow-y: auto; max-height: 400px;"
>
  <div role="rowgroup">
    <div forTableHeaderRow style="display: contents;">
      <div forTableHeaderCell name="name" sticky style="position: sticky; top: 0;">Name</div>
      <div forTableHeaderCell name="role" sticky style="position: sticky; top: 0;">Role</div>
    </div>
  </div>
  <div role="rowgroup">
    @for (row of rows(); track row.id) {
    <div forTableRow style="display: contents;">
      <div forTableCell name="name">{{ row.name }}</div>
      <div forTableCell name="role">{{ row.role }}</div>
    </div>
    }
  </div>
</div>

Declarative columns (ForTableColumnDef + <for-table-body>)

Hand-writing every cell in the header row and the data row keeps the two in sync by hand and smears a single column across several places. The optional [forTableColumnDef] + <for-table-body> layer stamps both rows out of one column definition, and carries the row variants, interleaved placeholders, whole-row navigation lists, measured row heights, and per-datum row styling built on top of it. It is additive: the raw primitives above keep working unchanged, and a table that never imports ForTableBody never bundles it.

Importing it does pull forty-cdk/drag-drop in with it, because the body stamps the drag pieces a reorderable column needs and a directive can only be applied from the template that declares it. Those bytes are retained whether or not any column is reorderable. They come to 18.0 kB raw / 5.1 kB gzip on a production build (#1730), a third of what the declarative layer costs over hand-written cells.

→ Table: declarative columns

Sticky header + CSS custom property

ForTable measures the header row height with ResizeObserver and exposes it as --for-table-header-height on the root host (the header row must generate a box, so use display: grid / flex on [forTableHeaderRow], not display: contents). The rules below are written for rows that are boxes of their own: that header row, the absolutely positioned rows of a virtualized grid, and every row <for-table-body> stamps. The header row sticks to the top, and a sticky column sticks its header and data cells alike to the inline edges:

[forTableHeaderRow] {
  position: sticky;
  top: 0;
  z-index: 2;
}

[forTableHeaderRow],
[forTableRow] {
  min-width: min-content;
}

[forTableHeaderCell][data-sticky=''],
[forTableCell][data-sticky=''] {
  position: sticky;
  inset-inline-start: 0;
  z-index: 1;
}

[forTableHeaderCell][data-sticky='end'],
[forTableCell][data-sticky='end'] {
  position: sticky;
  inset-inline-end: 0;
  z-index: 1;
}

A sticky cell never leaves its row's box, so a row only as wide as the scroller lets its start-edge cells scroll away once the column tracks add up to more. min-width: min-content floors the row at the width of its tracks: while they fit, width: 100% (or the left: 0; right: 0 of a windowed row) still sets the width and fr tracks keep shrinking, and once they overflow the row grows with them and its sticky cells stay put. The logical offsets follow the table's resolved dir, so the same rules pin the right-hand columns in rtl. The <div> mode example above keeps top: 0 on its header cells instead, because its header row is display: contents and generates no box to stick.

--for-table-header-height places anything else that sticks below the header without a hard-coded offset that drifts when the header content wraps. In a non-virtualized grid, a full-span group row, found by the data-row-variant hook on its cell, stays under the header as its section scrolls by:

[forTableRow]:has([data-row-variant]) {
  position: sticky;
  top: var(--for-table-header-height, 0px);
  z-index: 1;
}

End-edge sticky cells use sticky="end" on the directive:

<th forTableHeaderCell name="actions" sticky="end">Actions</th>
<td forTableCell name="actions" sticky="end">…</td>

Grid mode

mode="grid" (or mode="treegrid") turns the header and data cells into a single-tab-stop roving group with 2D keyboard navigation. The header row is the grid's first row: Tab reaches the whole grid once, Arrow keys cross between the header row and the body, and Enter / F2 reach a widget rendered inside a cell. Disabled cells (set via the cell's disabled input) are skipped during navigation, and all horizontal movement is RTL-mirrored when the resolved writing direction is rtl. The whole keymap (cell navigation, cell entry and its Tab cycle, selection, expansion and the reordering lifts) is collected under Keyboard.

The root emits aria-rowcount and aria-colcount. Per ARIA 1.2 and the APG Data Grid example, the header row counts as the grid's first row: it carries aria-rowindex="1", the first data row is aria-rowindex="2", and aria-rowcount includes the header row (defaulting to the rendered data-row count + 1). Override the data-row total for server-paged or virtualized tables via the rowCount input (the header offset is still added); override the column total via colCount. When no channel knows a total, the attribute reports -1, the value ARIA reserves for an unknown total, rather than the 0 that would claim the grid has no columns (or no rows) at all. The two channels reach that state differently. aria-rowcount reports -1 only in a virtualized grid, whose mounted rows are a slice of the dataset: with no [rowCount] and no <for-table-body> dataset to count, the total is unknowable whether or not rows are mounted (and it is also what the server-rendered markup carries, before the first window resolves). That is the shape of an append-style list whose server returns no total: bind [virtualRowCount] to the loaded count and leave [rowCount] unbound, and each row keeps its absolute aria-rowindex under an unknown total instead of a count smaller than that index, while cross-window keyboard navigation stays bounded by the loaded rows. A non-virtualized grid's rendered rows are all its rows, so an empty one with a header row reports aria-rowcount="1" and one with no header at all reports 0. aria-colcount reports -1 whenever no cell has registered, virtualized or not: rows without cells (or no markup at all) is degenerate either way, so 0 would never be a resolved answer there. An explicit [rowCount] / [colCount] is emitted verbatim, including 0. Data cells emit aria-colindex (1-based) and data-highlighted on the currently focused cell; header cells joining the roving grid emit aria-colindex too.

<div forTable mode="grid" ariaLabel="People" [rowCount]="totalRows">
  <div role="rowgroup">
    <div forTableHeaderRow>
      <div forTableHeaderCell name="name">Name</div>
      <div forTableHeaderCell name="role">Role</div>
    </div>
  </div>
  <div role="rowgroup">
    @for (row of rows(); track row.id) {
    <div forTableRow>
      <div forTableCell name="name" [disabled]="row.disabled">{{ row.name }}</div>
      <div forTableCell name="role">{{ row.role }}</div>
    </div>
    }
  </div>
</div>
[forTableCell][data-highlighted] {
  outline: 2px solid blue;
}

[forTableCell][data-disabled] {
  opacity: 0.4;
}

Activating a cell

Enter keeps its APG cell-entry meaning on a cell that holds a widget. On a data cell that holds none, the root emits (cellActivate) with the owning row's [value] (undefined when the row has none), the cell's column name and the Enter event, already preventDefaulted. A grid can therefore open its record from the keyboard without spending a column on a link or a button. F2, header cells, disabled cells and mode="table" never emit, and a sortable header keeps sorting on Enter.

<div forTable mode="grid" ariaLabel="Requests" (cellActivate)="open($event.row, $event.column)">
  …
</div>

With interactiveRows, <for-table-body> forwards the same Enter as (rowActivate), so a grid gets the keyboard half of whole-row activation while its rows still take no tab stop.

A row you render yourself gets both halves from (activate) on [forTableRow]: it fires for a click anywhere on the row except an interactive descendant, and for the same Enter as (cellActivate), with the row's [value] and the event. The row binds no (click), so the keyboard half is never lost, and Enter that enters a widget, Enter or a click from an inner control, and mode="table" never emit.

<div forTable mode="grid" ariaLabel="Requests" selectionBehavior="none">
  <div role="rowgroup">
    @for (request of requests(); track request.id) {
    <div forTableRow [value]="request.id" (activate)="open(request)">…</div>
    }
  </div>
</div>

Controls inside a cell

The library's in-cell pieces ([forTableRowSelector], [forTableSelectAll], [forTableColumnResizer]) are their own tab stop in mode="table" and take tabindex="-1" in grid / treegrid, where cell entry reaches them and the grid keeps its single tab stop. The cells do not demote their descendants, so a control you add to a cell follows the same rule through injectTableCellTabIndex(). It answers 0 outside a [forTable], so a control that also renders elsewhere can call it unconditionally:

import { Directive } from '@angular/core';
import { injectTableCellTabIndex } from 'forty-cdk/table';

@Directive({
  selector: 'button[appPriorityPicker]',
  host: { '[attr.tabindex]': 'tabindex()' },
})
export class PriorityPicker {
  protected readonly tabindex = injectTableCellTabIndex();
}

Full-span rows (group separators, section headers, summaries)

A grouped list interleaves presentational rows between its data rows (a date heading, a section separator, a totals line), and those rows span every column instead of holding one cell per column. Author the span with [forTableVariantCell], never with a single [forTableCell]: it stamps the same markup <for-table-body> stamps for a [forTableRowCellDef] variant row (role="gridcell", aria-colindex="1", an aria-colspan covering the grid's columns, and the data-row-variant hook) while registering no cell handle.

That last part is the contract. A full-span row that registers a cell would enter the grid as a one-cell row: it would redefine the column count (collapsing a 20-column grid to a single column the moment a separator is the first row with cells, which under [forTableVirtualized] is whichever row the scroll window starts on), shift the row-major cell mapping every arrow key derives its position from, and drop the header row out of the composite tab stop. With [forTableVariantCell] the row contributes nothing to the grid: arrow keys step over it onto the next data row, the column count keeps coming from the data rows around it, and the header row still joins. The row itself is still a real row that counts towards aria-rowindex / aria-rowcount, and it is non-selectable by contract because it carries no [value].

Spanning the row visually stays yours: grid-column: 1 / -1 in a <div> grid, or a colspan attribute in a native <table>.

<div forTable mode="grid" ariaLabel="People">
  <div role="rowgroup">
    <div forTableHeaderRow>
      <div forTableHeaderCell name="name">Name</div>
      <div forTableHeaderCell name="role">Role</div>
    </div>
  </div>
  <div role="rowgroup">
    @for (group of groups(); track group.label) {
    <div forTableRow>
      <div forTableVariantCell>{{ group.label }}</div>
    </div>
    @for (row of group.rows; track row.id) {
    <div forTableRow [value]="row.id">
      <div forTableCell name="name">{{ row.name }}</div>
      <div forTableCell name="role">{{ row.role }}</div>
    </div>
    } }
  </div>
</div>
[data-row-variant] {
  grid-column: 1 / -1;
}

Treegrid mode

mode="treegrid" sets role="treegrid" on the root. Rows are a flat sibling list in the DOM; hierarchy is expressed through ARIA attributes, not DOM nesting.

  • [level]: 1-based tree depth, reflected as aria-level. Default 1.
  • [expandable]: marks a row as a parent; emits aria-expanded="true"|"false" and data-state="open"|"closed". Leaf rows emit neither.
  • [(expanded)]: two-way bindable readonly T[] of open parent-row values (keyed by row [value]), the same shape ForTree.expanded uses for its open nodes. Use compareWith for object values.
  • aria-posinset / aria-setsize: auto-recomputed from the rendered flat list on every expand/collapse.
  • ArrowRight / ArrowLeft: expand / collapse the focused parent row, falling through to grid cell navigation on a leaf or a row already in that state; RTL-mirrored. See Keyboard.
  • Consumer mounts/unmounts child rows with @if driven by expanded(). A #r="forTableRow" template ref exposes r.toggleExpanded() for pointer-driven expand buttons.
<div forTable mode="treegrid" [(expanded)]="expanded">
  <div role="rowgroup">
    @for (row of visibleRows(); track row.id) {
    <div
      forTableRow
      #r="forTableRow"
      [value]="row.id"
      [level]="row.level"
      [expandable]="row.expandable"
    >
      <div forTableCell name="name">
        @if (row.expandable) {
        <button
          type="button"
          class="row-toggle"
          [attr.aria-label]="'Toggle ' + row.name"
          (click)="r.toggleExpanded()"
        >
          <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
            <path
              d="m8.25 4.5 7.5 7.5-7.5 7.5"
              fill="none"
              stroke="currentColor"
              stroke-width="1.75"
              stroke-linecap="round"
              stroke-linejoin="round"
            />
          </svg>
        </button>
        } {{ row.name }}
      </div>
    </div>
    }
  </div>
</div>
readonly expanded = signal<readonly string[]>([]);
readonly visibleRows = computed(() => {
  const openIds = this.expanded();
  return this.allRows.filter((row) => row.parentId === null || openIds.includes(row.parentId));
});

Style hooks:

[forTableRow][data-state='open'] {
  background: #f0fff0;
}
[forTableRow][data-state='closed'] {
  background: #fff0f0;
}
[forTableRow][aria-level='2'] {
  padding-left: 2rem;
}
[forTableRow][data-state='open'] .row-toggle svg {
  transform: rotate(90deg);
}

Row selection

Add selectionMode to [forTable] to enable row selection. Use [forTableRowSelector] for an accessible per-row selection checkbox and [forTableSelectAll] in the header for a tri-state select-all checkbox.

selectionMode

  • 'none' (default): selection is disabled. No aria-selected or aria-multiselectable is emitted.
  • 'single': at most one row can be selected. Selectable rows emit aria-selected="true" or "false".
  • 'multiple': any number of rows can be selected. In grid / treegrid mode the root emits aria-multiselectable="true"; in table mode it is never emitted (WAI-ARIA does not permit aria-multiselectable on role="table").

Only rows carrying a [value] are selectable and emit aria-selected. A row without a [value] (e.g. a group header, separator or summary rendered as a full-span variant row) is non-selectable by contract and emits no aria-selected, even when selectionMode is not 'none'.

selectionBehavior

Controls how a row click (on the row or on a cell) mutates the selection:

  • 'toggle' (default): clicking a row always flips its selected state.
  • 'replace': clicking a row replaces the selection with that single row. Modifier keys in 'multiple' mode: Ctrl/Cmd-click toggles the clicked row without clearing others; Shift-click extends a range from the last anchor to the clicked row.
  • 'none': clicking a row never mutates the selection. The rows stay selectable, so aria-selected and aria-multiselectable are emitted exactly as before, and [forTableRowSelector], [forTableSelectAll] and Space on a focused grid cell keep driving the selection. The range anchor is untouched too, so a later Shift-click still extends from where the selector left it.

'none' is the "checkbox selects, row opens the record" shape of the common data-table on the web. It frees the row click for whole-row activation while selection keeps its legal announcement: pair it with interactiveRows on <for-table-body> (see Whole-row navigation lists), or with your own (click) when you render the rows yourself.

<div
  forTable
  mode="grid"
  ariaLabel="Records"
  selectionMode="multiple"
  selectionBehavior="none"
  [(value)]="selection"
>
  <for-table-body
    [rows]="rows()"
    [rowKey]="rowKey"
    interactiveRows
    (rowActivate)="open($event.row)"
  >
    <!-- a [forTableRowSelector] column plus the data columns -->
  </for-table-body>
</div>

When you render the rows yourself in grid / treegrid mode, bind (activate) on each [forTableRow] instead (see Activating a cell): one binding covers the click and the Enter, and a click on an inner control is left to that control. In mode="table", where (activate) never fires, guard a row listener of your own with eventFromInteractiveDescendant(event) from forty-cdk/table, the definition the table's own row interactions use:

protected openFromRow(request: Request, event: MouseEvent | KeyboardEvent): void {
  if (eventFromInteractiveDescendant(event)) return;
  this.open(request);
}

Interactive content in a data cell owns its click. A click on a per-row action <button> (or an <a href>, <input>, <select>, <textarea>, <summary>, or contenteditable descendant) runs that control without also changing the row's selection. A selectable table with a trailing actions column therefore behaves as expected, and you never have to stopPropagation() on every control. A plain click anywhere else on the row (cell text, the gaps between cells, the row itself) still selects, including the Ctrl/Cmd/Shift modifier behaviour above. [forTableRowSelector] is unaffected. This mirrors the whole-row-activation guard in Whole-row navigation lists.

[(value)]

A two-way-bindable model<readonly T[]>(). ForTable<T> infers the row-value type T from this binding (unknown when unbound), so compareWith, selectableValues, and the selection methods specialize accordingly. Single mode keeps 0–1 entries. The implicit valueChange output fires only on internal mutations (selector / row click / Space / select-all). Consumer writes to the bound signal are reflected on the next change-detection cycle.

compareWith

An equality comparator (a: T, b: T) => boolean used for membership checks. Defaults to ===. Supply an id-based comparator when rows carry objects:

protected readonly idEquals = (a: Person, b: Person) => a.id === b.id;

Space-to-select on a focused grid cell

In mode="grid" with selectionMode set to 'single' or 'multiple', pressing Space on a focused data cell (event.target === cellHost) toggles the enclosing row's selection and prevents the default scroll. Space originating from a nested element (e.g. a button inside the cell) is ignored.

[forTableRowSelector]

Accessible per-row selection checkbox (place inside any cell of each [forTableRow]). Renders role="checkbox" and reflects aria-checked plus data-state="checked" | "unchecked". In mode="table" it is the focusable keyboard selection path: Tab to it, then Space / Enter to toggle. In grid / treegrid mode it stays out of the roving tab order (tabindex="-1") and selection is driven from the cell (Space). Clicking (or Space / Enter) calls toggleRowSelection on the row; a stopPropagation prevents the outer row-click handler from double-toggling. The enclosing row still owns the announced aria-selected. Give the selector an accessible name via [ariaLabel], and give the row a [value] input to make it selectable.

<div forTableRow [value]="row.id">
  <div forTableCell name="sel">
    <span forTableRowSelector [ariaLabel]="'Select ' + row.name">☑</span>
  </div>
  <div forTableCell name="name">{{ row.name }}</div>
</div>

Select-all checkbox

Interactive header checkbox with tri-state. Reflects aria-checked and data-state derived from the aggregate selection state across all selectable rows. Clicking (or pressing Space / Enter) selects all when none or some are selected, and deselects those rows when all are, leaving any selected value outside them in [(value)]. No-op outside 'multiple' mode. A selection the table cannot enumerate drives it from outside through [state]. Apply on a focusable element:

<div forTableHeaderRow>
  <div forTableHeaderCell name="sel">
    <span forTableSelectAll ariaLabel="Select all rows"></span>
  </div>
  <div forTableHeaderCell name="name">Name</div>
</div>

Total-aware aggregates under virtualization: [selectableValues]

By default the select-all tri-state, toggleSelectAll, and Shift-click range selection compute against the registered (rendered) rows. Under [forTableVirtualized] only the windowed rows are registered, so these aggregates would otherwise see only the visible slice: select-all would report "all" once every visible row is selected, and a range could not span unmounted rows.

Supply the full ordered set of selectable values via [selectableValues] so the aggregates compute against the true dataset instead of the window:

  • The select-all tri-state reflects the current selection vs. the full set, so it stays correct across scrolling.
  • toggleSelectAll() selects / deselects every supplied value, and leaves a selected value outside the supplied set in place.
  • Shift-click range resolves against the supplied order, so a range can span rows that are not currently mounted.

Per-row selection ([forTableRowSelector], row click, Space) is unaffected, because it persists in the bound [(value)] array regardless of mount state. Leave [selectableValues] unset (null, the default) for non-virtualized tables to keep the registered-rows behaviour.

<div
  forTable
  forTableVirtualized
  mode="grid"
  selectionMode="multiple"
  [rowCount]="people().length"
  [selectableValues]="peopleIds()"
  [(value)]="selection"
>
  <!-- header with [forTableSelectAll], windowed rows with [forTableRowSelector] -->
</div>
protected readonly peopleIds = computed(() => this.people().map((p) => p.id));

Selection the table cannot enumerate: [state]

[selectableValues] needs every value in hand. A server-paged list offering "select all matching the filter" has thousands of matches and one or two pages loaded, so its selection is a predicate ("everything the query matches, minus these exclusions") whose tri-state only its owner knows. Bind that tri-state to [forTableSelectAll]'s [state] and handle (toggleAll): the checkbox reflects your state in aria-checked / data-state, and a click, Space or Enter emits toggleAll instead of writing the table's [(value)]. Feed [(value)] the loaded rows your predicate selects, so each row's aria-selected stays right. The tabindex still follows the table mode.

<div forTableHeaderCell name="sel">
  <button
    type="button"
    forTableSelectAll
    ariaLabel="Select all matching requests"
    [state]="matchState()"
    (toggleAll)="toggleAllMatching()"
  ></button>
</div>
protected readonly matchState = computed<TableSelectAllState>(() => {
  if (this.allMatching()) {
    return this.excluded().size === 0 ? 'all' : 'some';
  }
  return this.picked().size > 0 ? 'some' : 'none';
});

Minimal multiple-select example

<div forTable mode="grid" selectionMode="multiple" selectionBehavior="toggle" [(value)]="selection">
  <div role="rowgroup">
    <div forTableHeaderRow>
      <div forTableHeaderCell name="sel">
        <span forTableSelectAll ariaLabel="Select all rows"></span>
      </div>
      <div forTableHeaderCell name="name">Name</div>
    </div>
  </div>
  <div role="rowgroup">
    @for (row of rows(); track row.id) {
    <div forTableRow [value]="row.id">
      <div forTableCell name="sel">
        <span forTableRowSelector></span>
      </div>
      <div forTableCell name="name">{{ row.name }}</div>
    </div>
    }
  </div>
</div>

Sortable headers

[forTableSortHeader] turns a [forTableHeaderCell] into a sortable affordance. It emits aria-sort and fires sortChange on activation (click, Enter, Space). The directive never sorts data: the consumer reorders their own rows from the sortChange payload.

The directive is self-contained: it owns only its own direction state. The "one sorted column at a time" guarantee is the consumer's responsibility. Hold a single sort descriptor signal and derive each header's direction from it:

<div forTableHeaderRow>
  <div
    forTableHeaderCell
    name="name"
    forTableSortHeader
    column="name"
    [direction]="directionFor('name')"
    (sortChange)="onSort($event)"
  >
    Name
  </div>
</div>
protected readonly sort = signal<TableSortDescriptor>({ column: '', direction: 'none' });
protected directionFor(column: string): TableSortDirection {
  return this.sort().column === column ? this.sort().direction : 'none';
}
protected onSort(descriptor: TableSortDescriptor): void {
  this.sort.set(descriptor);
}
protected readonly sortedRows = computed(() => /* the consumer sorts rows() by this.sort() */);

The direction cycles none → ascending → descending → none. Set disableClear to make the cycle skip none: ascending ↔ descending. Set firstClickDirection="descending" to make a freshly activated column start descending: none → descending → ascending → none (and with disableClear, none → descending → ascending → descending, which is the descending-first-with-toggle behavior a single always-active sort descriptor needs). When sortable is false the header is fully inert (no tabindex, no aria-sort, no-op handlers), which is useful when sorting is conditionally enabled. In mode="table" a sortable header is a tabindex="0" tab stop; in mode="grid" / mode="treegrid" the header cell owns the roving composite tab stop instead, so the sort header adds no separate tabindex (the [forTableHeaderCell] is the single owner of the host tabindex). Because the directive coordinates nothing across columns, the single-sort descriptor pattern above is what enforces that only one column is sorted at a time.

In mode="grid" / mode="treegrid" a sortable header cell reflects data-sortable. That marker hands it the cell's Enter key while F2 stays the APG cell-entry key, so a sortable + resizable header never both sorts and drops focus onto the resize handle on one press. How Enter and Space split three ways once a [forDraggable] shares the cell is in Keyboard.

Column resizing

[forTableColumnResizer] turns a focusable element inside a [forTableHeaderCell] into a column-resize handle. It publishes the resolved width as the CSS custom property --for-table-col-<name>-width on the table root, so the consumer's layout can apply it. The directive never lays out columns itself; wiring --for-table-col-<name>-width into grid-template-columns (or a <col> / cell width in native <table> mode) is the consumer's job.

<div
  forTableHeaderRow
  style="grid-template-columns: var(--for-table-col-name-width, 200px) var(--for-table-col-role-width, 200px);"
>
  <div forTableHeaderCell name="name">
    Name
    <button
      forTableColumnResizer
      column="name"
      [(width)]="nameWidth"
      (resizeCommit)="onResize($event)"
      aria-label="Resize Name column"
    ></button>
  </div>
  <div forTableHeaderCell name="role">
    Role
    <button
      forTableColumnResizer
      column="role"
      [(width)]="roleWidth"
      (resizeCommit)="onResize($event)"
      aria-label="Resize Role column"
    ></button>
  </div>
</div>

Seed the initial width through the bound signal (nameWidth = signal(200)); the directive applies pointer / keyboard deltas on top of it. The same --for-table-col-<name>-width variable can be applied to a <col> width or an individual cell width in native <table> mode.

data-resizing (empty string) is present on the handle element while a pointer drag is active. Use it to style the resize cursor or highlight the column.

resizeCommit fires once per gesture (pointer-up after a drag, each arrow press) with a { column, width } payload. Bind it to persist the width. Live updates during a drag come through [(width)] / widthChange.

Arrow-key resize (ArrowLeft / ArrowRight) moves the width by [step] pixels per press, respecting [min] / [max]. In RTL, the directions are mirrored.

Escape (or a pointercancel) mid-drag restores the pre-drag width through [(width)] and emits no resizeCommit. Unmounting the handle mid-drag (the column is dropped, or resizable is toggled off) reverts too, but the destroyed [(width)] model can no longer emit, so the pre-drag width is reported through the [widthRevert] callback instead. Bind it as a function reference when you persist widths and columns can disappear during a gesture:

<button
  forTableColumnResizer
  column="name"
  [(width)]="nameWidth"
  [widthRevert]="onWidthRevert"
  aria-label="Resize Name column"
></button>
readonly onWidthRevert = ({ column, width }: TableResizeDescriptor): void => {
  this.persistedWidths.update((widths) => ({ ...widths, [column]: width }));
};

<for-table-body> wires this internally: a stamped handle destroyed mid-drag folds its pre-drag width back into [(columnWidths)], so the declarative layer needs no extra binding.

Size-to-content (auto-fit)

Opt in with [autoFit] to add the "double-click the handle to fit the column to its content" gesture. When set, a dblclick on the handle measures the widest natural width across the column's data cells (resolved through the table context, browser-only), clamps it to [min] / [max], applies it as the new [(width)], and emits resizeCommit, exactly like a drag or arrow press. Unset (default), dblclick is a no-op and the resize behaviour is unchanged.

<button
  forTableColumnResizer
  column="name"
  autoFit
  [(width)]="nameWidth"
  (resizeCommit)="onResize($event)"
  aria-label="Resize Name column"
></button>

The same action is callable imperatively through the directive's exportAs="forTableColumnResizer" (e.g. a "Fit to content" item in a column menu). fitToContent() returns the applied width:

<button forTableColumnResizer column="name" #resizer="forTableColumnResizer" ...></button>
<button (click)="resizer.fitToContent()">Fit column to content</button>

Include the header label

By default auto-fit measures the column's data cells only, so a long header over narrow data (a Department column of short codes) can end up truncated. Add [fitIncludesHeader] to fit to max(header label, …data cells) instead. Mark the header's label text with a sibling [forTableColumnLabel] so the resize handle and any sort affordance are excluded from the measurement. The directive measures that marked element, making no assumption about the header's DOM structure:

<th forTableHeaderCell name="dept">
  <span forTableColumnLabel>Department</span>
  <button
    forTableColumnResizer
    column="dept"
    autoFit
    fitIncludesHeader
    [(width)]="deptWidth"
    aria-label="Resize Department column"
  ></button>
</th>

Without a [forTableColumnLabel] marker present, [fitIncludesHeader] degrades gracefully to the data-cells-only behaviour. Default (fitIncludesHeader unset) is unchanged: the header is ignored.

Column & row reordering

[forTableColumnReorder] and [forTableRowReorder] are opt-in companion directives that compose the drag-drop primitive to make table headers and data rows reorderable, each wrapping [forDropList] via hostDirectives so the whole drag-drop surface stays available. The table never mutates the consumer's data: reorder handlers apply moveItemInArray to a local signal.

Marking a [forTableColumnDef] reorderable is the declarative twin of that composition and adds no bundle cost of its own: <for-table-body> carries forty-cdk/drag-drop for every consumer already (see Declarative columns for the measured figure). On the raw path you opt into drag-drop explicitly by importing these two directives, so a raw table that skips them pays nothing.

In grid / treegrid mode a reorderable header cell shares the composite header + body tab stop rather than taking one of its own, and its keys split three ways between the column lift, grid navigation and cell entry (see Keyboard). A column pinned with [dragDisabled] cannot be lifted and still owns the grid's tab stop while it is the roving cell; it is not announced as aria-disabled, because only its lift is disabled, not the column header the consumer can still sort, resize and navigate.

→ Table: column & row reordering

Virtualized rows

[forTableVirtualized] is opt-in and works only with <div role> grid mode: a native <table> cannot omit rows mid-body without the browser recalculating every column width. Set [rowCount] on [forTable] to the true total so aria-rowcount and the window size both stay honest. An append-style infinite list, whose loaded prefix is the only range the virtualizer can place, keeps [rowCount] at the server total and narrows the scroll range with the companion's own [virtualRowCount] instead. It ships from the forty-cdk/table-virtualization entry point, so neither the table nor @tanstack/virtual-core reaches a bundle that does not import it.

→ forty-cdk/table-virtualization → Table: virtualized rows

Table Virtualization

The opt-in row-virtualization companion for [forTable]: it builds a windowing core from the table's [rowCount] and exposes the visible slice for the consumer to render, plus cross-window roving keyboard navigation.

[forTableVirtualized] sits on the same element as [forTable] and works only with <div role> grid mode, because a native <table> cannot omit rows mid-body without the browser recalculating every column width. It owns no DOM of its own: the consumer renders virtualRows() with their own @for and positions each row with a translateY transform, or lets <for-table-body> own the sizer. The focused row stays mounted even when scrolled out of the window, so the roving-focused gridcell is never unmounted. SSR-safe: off-browser the window is empty and totalSize is the estimate-based total.

Ships from the forty-cdk/table-virtualization secondary entry point, not from forty-cdk/virtualization. It is the one adapter that composes both forty-cdk/table and forty-cdk/virtualization, so keeping it here is what lets a consumer who windows a plain list import forty-cdk/virtualization without pulling the table into their module graph.

Anatomy

<div
  forTable
  mode="grid"
  ariaLabel="People"
  [rowCount]="rows().length"
  forTableVirtualized
  [estimateRowSize]="44"
  #v="forTableVirtualized"
  style="height: 400px; overflow: auto"
>
  <div role="rowgroup" style="position: relative" [style.height.px]="v.totalSize()">
    @for (vrow of v.virtualRows(); track vrow.index) {
    <div
      forTableRow
      [virtualIndex]="vrow.index"
      [value]="rows()[vrow.index]"
      style="position: absolute; inset-inline: 0"
      [style.transform]="'translateY(' + vrow.start + 'px)'"
    >
      <div forTableCell name="name">{{ rows()[vrow.index].name }}</div>
      <div forTableCell name="email">{{ rows()[vrow.index].email }}</div>
    </div>
    }
  </div>
</div>

Bind [rowCount] on [forTable] to the true total so aria-rowcount and the window size both stay honest, and give each rendered row its [virtualIndex] so aria-rowindex reports the absolute position rather than the position within the window.

API

ForTableVirtualized
PropertyTypeDescription
estimateRowSize
number
Estimated row size in px along the scroll axis.
Default: 44
scrollElement
HTMLElement | null
Scroll container; bind it when the container is an ancestor of the table.
Default: null (the table root)
virtualRowCount
number | undefined
Count of rows the virtualizer can place, which drives the scroll range and the cross-window navigation bound. Bind it for an append-style infinite list, whose placeable range is the loaded prefix rather than the server total.
Default: the table's [rowCount]
virtualRows
Signal
The visible window plus overscan, augmented with the focused and reordering rows.
totalSize
Signal
Total scroll size of all rows in px. Bind to the body container's height.
range
Signal
The true [firstIndex, lastIndex + 1) window, unaffected by retained rows.
scrollToRow
method
Scroll the container so the row at index is in view.
measureRow
method
Record the measured size of a rendered row element; null sweeps an evicted row.

Accessibility

Virtualization renders only a window of rows, so the table must keep announcing the real totals: bind [rowCount] on [forTable] (it drives aria-rowcount) and [virtualIndex] on each rendered row (it drives aria-rowindex). An append-style list keeps [rowCount] at the server total and narrows the scroll range with [virtualRowCount] instead of lowering the announced total. Focus management is handled for you: the focused row is retained in the window so roving focus is never lost to an unmounted cell.

Two totals: [rowCount] and [virtualRowCount]

[rowCount] is the server-known total aria-rowcount reports; [virtualRowCount] is the count of rows the virtualizer can actually place. They default to the same number, and for an index-addressable dataset (page N is fetchable the moment the window reaches it), they should stay that way: [rowCount] alone is the whole configuration.

They diverge for an append-style infinite list, the load-30-concatenate-load-30-more shape. There the placeable range is the loaded prefix, so leave [rowCount] at the server total and bind [virtualRowCount] to the loaded count:

<div
  forTable
  forTableVirtualized
  mode="grid"
  [rowCount]="serverTotal()"
  [virtualRowCount]="loaded().length"
></div>

Without it the scroll range stretches over thousands of rows the virtualizer will never place, so the thumb shrinks to a sliver and the viewport scrolls into empty space. The only workaround is then to report the loaded count as aria-rowcount, which is wrong on every page but the last. [virtualRowCount] also bounds cross-window keyboard navigation, so Ctrl+End lands on the last loaded row instead of stashing a focus move that resolves only when a far page appends.

When the server returns no total at all, leave [rowCount] unbound and keep [virtualRowCount] on the loaded count. aria-rowcount then reports -1, the value ARIA reserves for an unknown total, for as long as the grid is windowed, and keyboard navigation still crosses the window up to the last loaded row.

A declarative <for-table-body> derives the loaded count from its own dataset for the navigation bound, but the scroll range still comes from these two inputs. Both shapes therefore reach the channel the same way, and raw-primitive rendering (no <for-table-body>) has no other way to reach it at all.

Measured row heights

Pass each rendered row element to measureRow after every render when heights vary, and the core replaces the estimate with the measured size. A [forTableRow] reflects its [virtualIndex] as data-index, which is how the core knows which row it is measuring, so tag the row with a template reference and nothing else:

<div forTableRow [virtualIndex]="vrow.index" #row>…</div>
import { afterEveryRender, type ElementRef, viewChild, viewChildren } from '@angular/core';
import { ForTableVirtualized } from 'forty-cdk/table-virtualization';

private readonly v = viewChild.required(ForTableVirtualized);
private readonly rowEls = viewChildren<ElementRef<HTMLElement>>('row');

constructor() {
  afterEveryRender(() => {
    for (const row of this.rowEls()) {
      this.v().measureRow(row.nativeElement);
    }
    this.v().measureRow(null);
  });
}

Passing null sweeps a row recycled out of the window from the measurement cache. A declarative <for-table-body> does all of this for you when its measureRows input is set.

Keyboard navigation across the window

Arrow / Page / Ctrl+Home / Ctrl+End grid actions that resolve a row outside the rendered window scroll that row into view and move roving focus onto the target cell once it mounts, preserving the current column. [forTable] stays unaware of virtualization: it delegates the row-crossing move through the table context, so nothing in forty-cdk/table imports this entry point.

Infinite scroll

range is the true rendered window as an inclusive-exclusive [firstIndex, lastIndex + 1) range, sourced from the underlying virtualizer rather than from virtualRows() (which is augmented with the focused and reordering rows). It plugs straight into injectInfiniteScroll:

readonly detector = injectInfiniteScroll({
  range: this.v().range,
  count: this.loaded,
  onLoadMore: () => this.loadNextPage(),
});

Wrapping in a design system

[forTable]'s two composed surfaces each wrap differently: the root brings its own providers to whatever carries it, and the declarative body's defs register themselves through DI rather than being content-queried.

Wrapping the root

Composing with hostDirectives: [ForTable] needs nothing special, because a host directive brings its own providers to the element.

Subclassing the root does: Angular does not inherit a directive's providers, so a subclass carrying its own @Directive metadata replaces the array wholesale. [forTable] provides an internal registry that its own constructor injects and every piece resolves through. That registry is deliberately not exported, so a subclass with a hand-written provider list fails to construct (NG0201). Spread provideForTable instead, which installs the whole set and keeps the wrapper in step when the library changes it:

import { Directive, input } from '@angular/core';
import { ForTable, provideForTable } from 'forty-cdk/table';

@Directive({
  selector: '[myTable]',
  exportAs: 'myTable',
  providers: provideForTable(MyTable),
  host: { '[style.--my-table-cols]': 'columns() || null' },
})
export class MyTable extends ForTable {
  readonly columns = input<string>('');
}

The argument is the subclass, so the public FOR_TABLE_CONTEXT aliases it and an advanced consumer injecting the context reaches your instance. Add { provide: ForTable, useExisting: MyTable } alongside if you also want inject(ForTable) to resolve.

The rest of the wrapper story (what a wrapper must not swallow, and the plain re-provide every other composed root needs) is in Wrapping non-form roots.

Wrapping the declarative body

<for-table-body> does not content-query its building blocks: every [forTableColumnDef] / [forTableRowDef] / [forTableColumnDragPlaceholder] / [forTablePlaceholderCellDefault] registers itself with the surrounding def registry through DI at construction, and registrations are exposed in document order. That makes the two authoring shapes a design system layers on top expressible, and both are recipes rather than new API surface:

  • A preset column component (<my-text-column name="code" [header]="…" [value]="…" /> collapsing the recurring header / data / placeholder block into one line) needs nothing extra. 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. A content query never could, because a view is not content.
  • A scaffold wrapper table (<my-data-table> whose template owns the [forTable] root, the body and the shared row defs, with consumer columns arriving through <ng-content>) needs one seam: those projected defs are content of the wrapper, not of the inner body, so the wrapper declares providers: provideForTableDefRegistry() and binds inject(FOR_TABLE_DEF_REGISTRY) to the body's [defs]. The wrapper's own baked-in defs go next to the projected ones, outside the <for-table-body> element, so they reach the same registry.

A def with no reachable registry throws a [forty-cdk/table] error (it used to be silently inert), and subclassing ForTableBody is not a supported wrapping shape. Compose it in a wrapper's template instead. Unlike [forTable], spreading a provider helper does not rescue a subclass here: a component subclass inherits neither template nor imports, so it renders none of the body. A subclass whose @Component drops provideForTableDefRegistry() says so in a [forty-cdk/table] error rather than failing with NG0201.

→ Both recipes in full: the Wrapping the declarative body section of the declarative columns guide