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. Both run as memoised computeds against
the
treeinput plus the liveexpandedIdsset. - Expand/collapse state via the
expandedIdsmodel. Bound consumers own the value; unbound consumers see a default fully-expanded set on first render via an init effect that seeds fromflatNodes. - Selection state via the
selectedIdsmodel, reconciled againstselectionModechanges ('none'clears;'single'truncates). - Keyboard navigation state via the
focusedNodeIdsignal (the logical focus row, distinct fromdocument.activeElement). - 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).
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#
Relationships
Index#
Properties
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 on first render and continues to manage
its own state.
<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.
TreetableOptionsPer-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.
<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.
falseBind 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" />(node: FlatNode) => unknown 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.idNode | Node[] The tree data to display. Accepts either a single root Node 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 on first render and continues to manage
its own state.
<cngx-treetable [(expandedIds)]="myIds" />FlatNodeFires once per row activation, whether by mouse click or by keyboard
(Enter/Space while the row holds logical focus). Carries the full
FlatNode so listeners can read depth, parent chain, raw value,
or hasChildren without re-resolving the id against the tree.
FlatNodeFires when a node transitions from expanded to collapsed. Sibling
of nodeExpanded; same fire-once-per-transition contract.
FlatNodeFires 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.
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.
<cngx-treetable [(selectedIds)]="myIds" />Instance Properties#
string | nullLogical focus tracker - the id of the row that should be styled as
focused, decoupled from document.activeElement. Updated by
handleRowClick (on click activation) and handleKeyDown (on arrow
navigation, Home/End, ArrowLeft jump-to-parent).
Templates apply :focus-visible-style chrome based on this signal,
so the focus ring renders the same way whether the user reached the
row by mouse or by keyboard. null while no row has ever been
focused.
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<TreetableOptions<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);
},
},
)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.
| 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 |
KeyboardEventhandleRowClick(node: FlatNode) 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.
FlatNodeisSelected(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: FlatNode) 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.
FlatNodeVisibility-bounded select-all toggle. If every currently visible
row is selected, clears the selection. Otherwise selects every
currently visible row - 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.
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- keyboard-cursor row tint--selected- selection-state row tint--focused- inset focus-ring outline (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__toggle- the expand/collapse button itself; hover swaps to the header border + header text colours
Variants
- Narrow breakpoint (
max-width: 960px) - shrinks indent step, bumps tap-target padding on the toggle (>= 44px combined), and rebalances header / cell / utility padding. Threshold is pinned by--cngx-treetable-narrow-breakpoint(token only - the actual@mediaquery uses a literal960pxsince media queries cannot resolvevar()).
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
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
Motion
<time>120msTransition duration of the row background change.
<time>120msTransition duration of the toggle button hover state.
Variant / Narrow
<length>960pxWidth threshold at which the narrow / mobile overrides apply.
*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 Node,
type TreetableOptions,
} 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: Node<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: TreetableOptions<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<Node<ProjectNode>[]>(() => {
const term = this.search().trim();
if (!term) {
return PROJECT_TREE;
}
return filterTree(PROJECT_TREE, (value) => nodeMatchesSearch(value, term));
});
protected readonly sortedTree = computed<Node<ProjectNode>[]>(() =>
sortTree(this.filteredTree(), this.sortField(), this.sortDir()),
);
protected readonly allIds = computed<ReadonlySet<string>>(() => {
const ids = new Set<string>();
const visit = (nodes: Node<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());
}
}