CngxTreetable
projects/data-display/treetable/treetable.component.ts
Import#
import { CngxTreetable } from '@cngx/data-display/treetable'
Description#
Headless tree table built on Angular CDK Table.
Renders a fully unstyled, accessible tree table using CDK primitives.
All visual styling lives in treetable.component.css and resolves
through CSS custom properties so consumers can theme it freely.
What the component owns.
- Tree flattening via flattenTree and visible-node filtering
via
isNodeVisible(from@cngx/utils). Both run as memoised computeds against thetreeinput plus the liveexpandedIdsset. - Expand/collapse state via the
expandedIdsmodel. Bound consumers own the value; unbound consumers see a default fully-expanded set seeded from the first non-emptyflatNodes. The seed fires once per component - later tree swaps never re-expand a collapsed grid. - Selection state via the
selectedIdsmodel, reconciled againstselectionModechanges ('none'clears;'single'truncates). - Id hygiene across tree swaps: expansion and selection ids absent
from the new
flatNodesare pruned (the positional default ids would otherwise silently re-attach to different nodes). An empty forest is transitional and never pruned against. - The roving focus model:
focusedNodeIdtracks the last-focused row,effectiveFocusedIdreconciles it against the visible rows to keep exactly one row tab stop, and keyboard navigation moves real DOM focus (document.activeElementfollows). - Resolved column list including the synthetic
_expandcolumn and the optional_selectcheckbox column.
Async-state binding. When the optional state input is bound to
a CngxAsyncState, the isLoading / isRefreshing / isBusy
/ isEmpty / error computeds delegate to it. When unbound, those
computeds fall back to local-only defaults (isEmpty becomes
"no visible rows", everything else is false/null).
The body switches through the shared resolveAsyncView lookup table:
skeleton on first load, error surface with the grid gone, empty
surface after a load that produced nothing, a refresh indicator over
content that stays on screen, and content+error when a refresh
fails over loaded rows. Two corrections, mirrored from the timeline:
a first load over seed rows paints the rows (a skeleton would hide
them; aria-busy still marks the grid), and a non-first-load
loading/pending over an empty grid is treated as a load instead
of a blank region. A bound state that is still idle renders
nothing - start the load, or bind [state] only once it has started.
aria-busy on the host mirrors isBusy; view transitions reach AT
through a dedicated polite live region (loading / refreshing /
failure), separate from the bulk-selection announcer.
Basic usage
<cngx-treetable [tree]="orgTree" (nodeClicked)="onNodeClick($event)" />Custom cell template
<cngx-treetable [tree]="orgTree">
<ng-template [cngxCell]="'name'" let-node>
<strong>{{ node.value.name }}</strong>
</ng-template>
</cngx-treetable>Metadata#
Content Slots#
Host#
Relationships
Index#
Inputs
Derived State
HostBindings
Inputs#
ReadonlySetExpanded-id set. Two-way bindable via [(expandedIds)] or read-only via
[expandedIds]; the model's implicit expandedIdsChange output fires
after every toggle. When left unbound the component seeds itself with
the default fully-expanded set once - on the first non-empty tree - and
continues to manage its own state. Later tree swaps never re-seed, and
ids that vanish from the tree are pruned (with a change emit).
<cngx-treetable [(expandedIds)]="myIds" />new Set()(node: T, path: readonly number[]) => stringOptional function to derive a stable, domain-meaningful ID from a node's value and its path in the tree.
When omitted, IDs are generated from the node's path indices joined by -
(e.g. "0", "0-1", "0-1-2"), which are stable across re-renders as
long as the tree structure does not change.
CngxTreetableOptionsPer-instance display options that override the application-wide TreetableConfig provided via provideTreetable.
ReadonlySetSelected-id set. Two-way bindable via [(selectedIds)] or read-only via
[selectedIds]; the model's implicit selectedIdsChange output fires
after every selection toggle. Switching selectionMode to 'none' or
'single' reconciles the set down to a legal shape, and ids that
vanish from the tree after a swap are pruned (with a change emit).
<cngx-treetable [(selectedIds)]="myIds" />new Set()"none" | "single" | "multi"Row selection behaviour.
'none'- selection is disabled (default).'single'- at most one row can be selected at a time.'multi'- multiple rows can be selected simultaneously.
'none'When true, renders a checkbox column (_select) to the left of the data
columns. Only meaningful when selectionMode is 'single' or 'multi'.
In 'multi' mode a "select all" checkbox is shown in the column header.
falseHow many placeholder rows the first-load skeleton renders. Tune it to roughly the row count the loaded grid will show so the swap to content does not jump.
3Bind an async state so the treetable's loading / refreshing / empty / error computeds delegate to one source of truth. The cascade:
isLoadingmirrorsstate.isFirstLoad()isRefreshingmirrorsstate.isRefreshing()isBusymirrorsstate.isBusy()isEmptymirrorsstate.isEmpty(), falling back to "no visible rows" when the state reports unknownerrormirrorsstate.error()
When unbound, those computeds fall back to local defaults: isEmpty
still works against the visible-nodes count, the loading flags read
false, error reads null. Bind this when the data flow already
carries an injectAsyncState or createManualState source - skip
it when the consumer is fine with the local-only fallback.
<cngx-treetable [tree]="data()" [state]="loadState" />Custom identity function for CDK / Material table's trackBy.
Return any value that uniquely identifies a row across change-detection
cycles to avoid full row re-creation on data changes.
(node) => node.idThe tree data to display. Accepts either a single root CngxTreetableNode or an array of root nodes for a forest.
Outputs#
ReadonlySetExpanded-id set. Two-way bindable via [(expandedIds)] or read-only via
[expandedIds]; the model's implicit expandedIdsChange output fires
after every toggle. When left unbound the component seeds itself with
the default fully-expanded set once - on the first non-empty tree - and
continues to manage its own state. Later tree swaps never re-seed, and
ids that vanish from the tree are pruned (with a change emit).
<cngx-treetable [(expandedIds)]="myIds" />CngxTreetableFlatNodeFires once per row activation, whether by mouse click or by keyboard
(Enter/Space while the row holds logical focus). Carries the full
CngxTreetableFlatNode so listeners can read depth, parent chain, raw value,
or hasChildren without re-resolving the id against the tree.
CngxTreetableFlatNodeFires when a node transitions from expanded to collapsed. Sibling
of nodeExpanded; same fire-once-per-transition contract.
CngxTreetableFlatNodeFires when a node transitions from collapsed to expanded. Pairs with
expandedIdsChange, which carries the post-transition full id set;
nodeExpanded carries the specific node that flipped, useful when
the consumer wants to react to that one row rather than diff the set.
Fires when a projected error template invokes its retry context
callback. The treetable does not re-run anything itself - the
consumer owns the data flow and restarts the load on this signal.
The built-in error surface has no retry control; this output only
fires through a *cngxError (or config-tier error) template.
ReadonlySetSelected-id set. Two-way bindable via [(selectedIds)] or read-only via
[selectedIds]; the model's implicit selectedIdsChange output fires
after every selection toggle. Switching selectionMode to 'none' or
'single' reconciles the set down to a legal shape, and ids that
vanish from the tree after a swap are pruned (with a change emit).
<cngx-treetable [(selectedIds)]="myIds" />Instance Properties#
string | nullLogical focus tracker - the id of the row the user last focused.
Updated by handleRowClick (on click activation), handleKeyDown
(on arrow navigation, Home/End, ArrowLeft jump-to-parent), and the
row-level focused output (when DOM focus lands in a row via Tab
or click). null while no row has ever been focused.
The template never reads this raw signal - it binds against effectiveFocusedId, which reconciles the value against the currently visible rows.
signal<string | null>(null)unknownEffective per-instance options: application-wide TreetableConfig
(from provideTreetable(...)) overlaid by the per-instance options
input. Use this when a consumer needs to read the resolved option
(instance wins over app default) instead of re-implementing the
cascade.
computed<CngxTreetableOptions<T>>(
() => ({
...this.config,
...this.options(),
}),
{
equal: (a, b) => {
if (a === b) {
return true;
}
if (a.highlightRowOnHover !== b.highlightRowOnHover) {
return false;
}
if (a.capitaliseHeader !== b.capitaliseHeader) {
return false;
}
const ac = a.customColumnOrder;
const bc = b.customColumnOrder;
if (ac === bc) {
return true;
}
if (!ac || !bc) {
return false;
}
return arrayEqual(ac, bc);
},
},
)unknownSR live-region content for bulk selection changes. toggleAll
(header checkbox or Ctrl/Cmd+A) writes a polite announcement here;
the region itself stays in the DOM permanently, only its content
is reactive. Per-row toggles stay silent - the row's
aria-selected flip is the announcement.
this.selectionAnnouncementState.asReadonly()TrackByFunction> TrackByFunction wired to the trackBy input.
Pass directly to the CDK/Material table's [trackBy] binding.
() => {...}Methods#
handleKeyDown(event: KeyboardEvent)Keyboard navigation handler. Bind to the table's (keydown) event.
Modified keys (Ctrl / Alt / Meta) are left to the browser so user
shortcuts keep working - except Ctrl+A / Cmd+A, which toggles
select-all in 'multi' mode.
| Key | Action |
|---|---|
ArrowDown |
Focus next visible row |
ArrowUp |
Focus previous visible row |
ArrowRight |
Expand focused node (if collapsed) |
ArrowLeft |
Collapse focused node (if expanded), or jump to parent |
Enter / Space |
Activate focused row (handleRowClick) |
Home |
Focus first visible row |
End |
Focus last visible row |
Ctrl+A / Cmd+A |
Toggle select-all over the visible rows ('multi' mode only) |
KeyboardEventhandleRowClick(node: CngxTreetableFlatNode<T>)Single entry point for row activation, from any modality (mouse
click, Enter, Space). Three-step sequence in this order:
- Promote
node.idtofocusedNodeIdso the focus ring follows. - Emit
nodeClickedfor consumer-level activation handlers. - Delegate to
toggleSelection, which is a no-op in'none'mode.
isSelected(id: string)Predicate for "is this id in the current selection?". Reads the
selectedIds signal so callers inside templates (e.g. [checked]
bindings on the per-row checkbox) re-evaluate automatically when
the selection changes.
toggle(node: CngxTreetableFlatNode<T>)Flip the expand/collapse state of node. Writes the next id set into
expandedIds (the model's implicit expandedIdsChange output fires
automatically) and emits exactly one of nodeExpanded / nodeCollapsed.
Visibility-bounded select-all toggle, in both directions. If every
currently visible row is selected, deselects exactly those visible
rows. Otherwise selects every currently visible row. Either way,
rows hidden inside a collapsed parent stay untouched. Only acts in
'multi' mode; no-op in 'single' or 'none'. Drives the header
checkbox's click handler.
toggleSelection(node: CngxTreetableFlatNode<T>)Flip the selection state of node against the current selectionMode.
In 'single' mode the prior selection clears before the new id is added;
in 'multi' mode the toggle is per-id. No-op in 'none' mode.
HostBindings#
| Binding | Expression |
|---|---|
[attr.aria-busy] | isBusy() || null |
Default visuals for CngxTreetable - the CDK-based variant. The
host carries .cngx-treetable and wraps a cdk-table whose
cdk-header-row / cdk-row / cdk-header-cell / cdk-cell
children are styled directly. Indent is driven by --cngx-row-depth
(set per row by the renderer) multiplied by
--cngx-treetable-indent-size.
State modifiers
Applied to cdk-row by the renderer:
--highlighted- hover-highlight row tint (applied while the row is hovered, driven byhighlight() && hovered())--selected- selection-state row tint--focused- inset focus-ring outline on the keyboard-focused row (offset negative so the ring stays inside the row border)
Slots
BEM element classes set per cell or per slot:
__expand-cell- utility column holding the expand/collapse toggle; narrow inline padding, depth-indented start padding__select-cell- utility column holding the selection control; narrow inline padding on both sides__first-data-cell- first data column; depth-indented start padding so the tree hierarchy is visible without an explicit toggle column__empty- empty-state slot, centred and muted__skeleton/__skeleton-row/__skeleton-toggle/__skeleton-line- first-load placeholder restating the row raster; static (no shimmer), line color mixed from currentColor__refresh- muted refresh indicator below the grid while loaded rows stay on screen__error/__error-message- error surface, centred, message in the foundation danger color__sr- visually-hidden live regions (bulk-selection and async-state announcements); always in the DOM, content reactive__toggle- the expand/collapse button itself; hover swaps to the header border + header text colours
Variants
- Narrow rung (
@container cngx-treetable (max-inline-size: 48rem))- shrinks indent step, bumps tap-target padding on the toggle
(>= 44px combined), and rebalances header / cell / utility
padding. It queries the treetable's OWN width, not the viewport,
so a table in a drawer compacts on a desktop. The threshold is
the literal in that rule and nowhere else - a container query
cannot resolve
var().
- shrinks indent step, bumps tap-target padding on the toggle
(>= 44px combined), and rebalances header / cell / utility
padding. It queries the treetable's OWN width, not the viewport,
so a table in a drawer compacts on a desktop. The threshold is
the literal in that rule and nowhere else - a container query
cannot resolve
Inheritance
Border / text / focus-ring tokens delegate to the foundation so
brand overrides propagate without per-component theming. The
:root block re-points the tokens via var() because the
@property initial-values would otherwise shadow the chain:
--cngx-treetable-header-border->--cngx-color-border--cngx-treetable-row-border->--cngx-color-border--cngx-treetable-header-color->--cngx-color-text--cngx-treetable-cell-color->--cngx-color-text--cngx-treetable-focus-ring->--cngx-color-primary
Component-local surface tokens (header bg, hover bg, selected bg,
muted color) carry their own light/dark pairs - they sit
slightly above --cngx-color-surface in both modes so rows stay
distinguishable from the table background.
Dark mode
Three hooks swap header / hover / selected backgrounds plus the muted text color:
prefers-color-scheme: dark[data-color-scheme="dark"].darkclass
Foundation-delegated tokens (border / text / focus-ring) ride the foundation cascade unchanged.
Pair with
@cngx/themes/material/treetable-theme- Material 3 surface treatment
Index#
Surface
State / Highlighted
State / Selected
Layout
State / Empty
Typography
State / Loading
Variant / Narrow
Surface
<color>oklch(0.92 0.005 250)Bottom border under the header row. Falls back through
--cngx-color-border.
<color>oklch(0.96 0.005 250)Between-rows border color. Falls back through --cngx-color-border.
<color>oklch(0.5 0.015 250)Muted text color used by the empty state and the toggle button.
<length>2pxStroke width of the header bottom border.
State / Highlighted
<color>oklch(0.97 0.015 250)Background of the keyboard-highlighted row.
State / Selected
<color>oklch(0.95 0.025 250)Background of the selected row.
State / Focus
<color>oklch(0.66 0.19 50)Focus-ring outline color. Falls back through --cngx-color-primary.
See: [[--cngx-color-primary]]
Layout
*1.5remIndent step per tree depth level. Multiplied by --cngx-row-depth
to compute total inline-start padding.
*0.6remBlock-axis padding inside header and body cells.
*1remInline-axis padding inside header and body cells.
*0.25remCompact inline padding applied to the expand and select utility columns (header + body).
*0.25remPadding inside the expand/collapse toggle button.
<length>4pxCorner radius of the expand/collapse toggle button.
State / Empty
Typography
State / Loading
*12pxHeight of one skeleton placeholder line during a first load.
Motion
<time>120msTransition duration of the row background change.
Derives from the --cngx-duration-fast rung of the motion scale; a
--cngx-duration-* brand override re-times it library-wide.
@relatedTo provideMotion, CngxMotionScope, injectMotion
<time>120msTransition duration of the toggle button hover state.
Derives from the --cngx-duration-fast rung of the motion scale; a
--cngx-duration-* brand override re-times it library-wide.
@relatedTo provideMotion, CngxMotionScope, injectMotion
Variant / Narrow
<length>768pxDocumentation mirror of the narrow rung, for tooling that reads the
token table. The live threshold is the literal inside the
@container cngx-treetable (max-inline-size: 48rem) rule - a container
condition cannot resolve var(), so setting this token changes nothing.
Held as 768px, the absolute equivalent of 48rem at the default 16px
root, because a registered <length> must carry a
computationally-independent initial value: a relative unit makes the
browser drop the whole @property rule, inherits flag included.
*0.5remHeader cell block padding below the narrow breakpoint.
*0.625remHeader cell inline padding below the narrow breakpoint.
*0.875remBody cell block padding below the narrow breakpoint -bigger tap target.
*0.625remDefault body cell inline padding below the narrow breakpoint.
*0.125remCompact inline padding for utility cells below the narrow breakpoint.
*0.875remLarger utility inline padding used by the first-data-cell below the narrow breakpoint.
*1.25remIndent step per depth level below the narrow breakpoint - reduced so deep trees still fit on narrow screens.
*0.5remToggle button padding below the narrow breakpoint -bigger tap target (≥ 44 px combined).
*1remToggle button font-size below the narrow breakpoint.
Showcase
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { CngxSort, CngxSortHeader, type SortEntry } from '@cngx/common/data';
import {
CngxCellTpl,
CngxEmptyTpl,
CngxHeaderTpl,
CngxTreetable,
filterTree,
nodeMatchesSearch,
sortTree,
type CngxTreetableNode,
type CngxTreetableOptions,
} from '@cngx/data-display/treetable';
// Re-export forces compodocx to ship app.config.ts in the StackBlitz manifest.
export { appConfig } from './app.config';
interface ProjectNode {
code: string;
name: string;
status: 'active' | 'paused' | 'archived';
priority: 'low' | 'medium' | 'high';
hours: number;
}
type SortField = 'name' | 'hours' | 'code';
type SortDirection = 'asc' | 'desc';
type SelectionMode = 'none' | 'single' | 'multi';
const PROJECT_TREE: CngxTreetableNode<ProjectNode>[] = [
{
value: { code: 'PROJ-100', name: 'Mobile rewrite', status: 'active', priority: 'high', hours: 240 },
children: [
{ value: { code: 'PROJ-100-1', name: 'Auth flow', status: 'active', priority: 'high', hours: 80 } },
{
value: { code: 'PROJ-100-2', name: 'Profile screen', status: 'paused', priority: 'medium', hours: 64 },
children: [
{ value: { code: 'PROJ-100-2-1', name: 'Avatar upload', status: 'paused', priority: 'low', hours: 16 } },
],
},
],
},
{
value: { code: 'PROJ-200', name: 'API gateway', status: 'active', priority: 'medium', hours: 160 },
children: [
{ value: { code: 'PROJ-200-1', name: 'Rate limiter', status: 'archived', priority: 'low', hours: 24 } },
{ value: { code: 'PROJ-200-2', name: 'Auth middleware', status: 'active', priority: 'high', hours: 56 } },
],
},
{
value: { code: 'PROJ-300', name: 'Reporting v2', status: 'paused', priority: 'low', hours: 48 },
children: [
{ value: { code: 'PROJ-300-1', name: 'PDF templates', status: 'paused', priority: 'low', hours: 16 } },
],
},
];
interface ActivityEntry {
readonly kind: string;
readonly detail: string;
}
@Component({
selector: 'app-root',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CngxTreetable, CngxCellTpl, CngxHeaderTpl, CngxEmptyTpl, CngxSort, CngxSortHeader],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
})
export class AppComponent {
protected readonly search = signal('');
protected readonly sortField = signal<SortField>('name');
protected readonly sortDir = signal<SortDirection>('asc');
protected readonly selectionMode = signal<SelectionMode>('multi');
protected readonly expandedIds = signal<ReadonlySet<string>>(
new Set(['PROJ-100', 'PROJ-200', 'PROJ-300']),
);
protected readonly selectedIds = signal<ReadonlySet<string>>(new Set());
protected readonly events = signal<readonly ActivityEntry[]>([]);
protected readonly nodeId = (value: ProjectNode): string => value.code;
protected readonly options: CngxTreetableOptions<ProjectNode> = {
customColumnOrder: ['name', 'status', 'priority', 'hours'],
highlightRowOnHover: true,
};
// Filter and sort are orthogonal atoms in cngx - the treetable renders,
// the consumer shapes. Each transform is its own computed; the [tree]
// binding consumes the final stage of the cascade.
protected readonly filteredTree = computed<CngxTreetableNode<ProjectNode>[]>(() => {
const term = this.search().trim();
if (!term) {
return PROJECT_TREE;
}
return filterTree(PROJECT_TREE, (value) => nodeMatchesSearch(value, term));
});
protected readonly sortedTree = computed<CngxTreetableNode<ProjectNode>[]>(() =>
sortTree(this.filteredTree(), this.sortField(), this.sortDir()),
);
protected readonly allIds = computed<ReadonlySet<string>>(() => {
const ids = new Set<string>();
const visit = (nodes: CngxTreetableNode<ProjectNode>[]): void => {
for (const node of nodes) {
ids.add(node.value.code);
if (node.children) {
visit(node.children);
}
}
};
visit(PROJECT_TREE);
return ids;
});
protected readonly rootIds = computed<ReadonlySet<string>>(
() => new Set(PROJECT_TREE.map((n) => n.value.code)),
);
protected log(kind: string, detail: string): void {
this.events.update((entries) => [{ kind, detail }, ...entries].slice(0, 8));
}
protected asValue(event: Event): string {
return (event.target as HTMLInputElement | HTMLSelectElement).value;
}
// Sort state has one owner (the sortField/sortDir signals); the dropdown
// toolbar and the in-header cngxSortHeader buttons both feed into it.
// The CngxSort directive runs in controlled mode so the two writers stay
// in lockstep without an extra source of truth.
protected onSortChange(entry: SortEntry | undefined): void {
if (!entry) {
this.sortField.set('name');
this.sortDir.set('asc');
return;
}
this.sortField.set(entry.active as SortField);
this.sortDir.set(entry.direction);
}
protected asSortField(value: string): SortField {
return value as SortField;
}
protected asSortDir(value: string): SortDirection {
return value as SortDirection;
}
protected asSelectionMode(value: string): SelectionMode {
return value as SelectionMode;
}
protected clearSelection(): void {
this.selectedIds.set(new Set());
}
}