Skip to main content
cngx-src documentation

CngxTreetable

ComponentPrimaryOnPushNo encapsulationv0.1.0WCAG AA

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 tree input plus the live expandedIds set.
  • Expand/collapse state via the expandedIds model. Bound consumers own the value; unbound consumers see a default fully-expanded set on first render via an init effect that seeds from flatNodes.
  • Selection state via the selectedIds model, reconciled against selectionMode changes ('none' clears; 'single' truncates).
  • Keyboard navigation state via the focusedNodeId signal (the logical focus row, distinct from document.activeElement).
  • Resolved column list including the synthetic _expand column and the optional _select checkbox 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#

Inputs#

expandedIds#ReadonlySet

Expanded-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" />
default new Set()
nodeId#(node: T, path: readonly number[]) => string

Optional 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.

Per-instance display options that override the application-wide TreetableConfig provided via provideTreetable.

selectedIds#ReadonlySet

Selected-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" />
default new Set()
selectionMode#"none" | "single" | "multi"
input()

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.
default 'none'
showCheckboxes#boolean
input()

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.

default false

Bind an async state so the treetable's loading / refreshing / empty / error computeds delegate to one source of truth. The cascade:

  • isLoading mirrors state.isFirstLoad()
  • isRefreshing mirrors state.isRefreshing()
  • isBusy mirrors state.isBusy()
  • isEmpty mirrors state.isEmpty(), falling back to "no visible rows" when the state reports unknown
  • error mirrors state.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" />
trackBy#(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.

default (node) => node.id
Required

The tree data to display. Accepts either a single root Node or an array of root nodes for a forest.

Outputs#

expandedIds#ReadonlySet

Expanded-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" />
nodeClicked#FlatNode

Fires 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.

nodeCollapsed#FlatNode

Fires when a node transitions from expanded to collapsed. Sibling of nodeExpanded; same fire-once-per-transition contract.

nodeExpanded#FlatNode

Fires 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.

selectedIds#ReadonlySet

Selected-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#

focusedNodeId#string | null
signal()Readonly

Logical 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)
resolvedOptions#unknown
Readonly

Effective 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);
    },
  },
)
trackByFn#TrackByFunction>
Readonly

TrackByFunction wired to the trackBy input. Pass directly to the CDK/Material table's [trackBy] binding.

() => {...}

Methods#

handleKeyDown#void
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
@parameventKeyboardEvent
handleRowClick#void
handleRowClick(node: FlatNode)

Single entry point for row activation, from any modality (mouse click, Enter, Space). Three-step sequence in this order:

  1. Promote node.id to focusedNodeId so the focus ring follows.
  2. Emit nodeClicked for consumer-level activation handlers.
  3. Delegate to toggleSelection, which is a no-op in 'none' mode.
@paramnodeFlatNode
isSelected#boolean
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.

@paramidstring
toggle#void
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.

@paramnodeFlatNode
toggleAll#void

Visibility-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.

toggleSelection#void
toggleSelection(node: FlatNode)

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.

@paramnodeFlatNode

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 @media query uses a literal 960px since media queries cannot resolve var()).

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"]
  • .dark class

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

--cngx-treetable-header-bg#<color>
Default value oklch(0.98 0.005 250)

Header row background.

--cngx-treetable-header-border#<color>
Default value oklch(0.92 0.005 250)

Bottom border under the header row. Falls back through --cngx-color-border.

--cngx-treetable-header-color#<color>
Default value oklch(0.34 0.015 250)

Header cell text color.

--cngx-treetable-row-border#<color>
Default value oklch(0.96 0.005 250)

Between-rows border color. Falls back through --cngx-color-border.

--cngx-treetable-muted-color#<color>
Default value oklch(0.5 0.015 250)

Muted text color used by the empty state and the toggle button.

--cngx-treetable-cell-color#<color>
Default value oklch(0.34 0.015 250)

Body cell text color.

--cngx-treetable-header-border-width#<length>
Default value 2px

Stroke width of the header bottom border.

--cngx-treetable-row-border-width#<length>
Default value 1px

Stroke width of the between-rows border.

State / Highlighted

--cngx-treetable-row-hover-bg#<color>
Default value oklch(0.97 0.015 250)

Background of the keyboard-highlighted row.

State / Selected

--cngx-treetable-row-selected-bg#<color>
Default value oklch(0.95 0.025 250)

Background of the selected row.

State / Focus

--cngx-treetable-focus-ring#<color>
Default value oklch(0.66 0.19 50)

Focus-ring outline color. Falls back through --cngx-color-primary.

See: [[--cngx-color-primary]]

--cngx-treetable-focus-ring-width#<length>
Default value 2px

Width of the row focus-ring outline.

Layout

--cngx-treetable-indent-size#*
Default value 1.5rem

Indent step per tree depth level. Multiplied by --cngx-row-depth to compute total inline-start padding.

--cngx-treetable-cell-padding-block#*
Default value 0.6rem

Block-axis padding inside header and body cells.

--cngx-treetable-cell-padding-inline#*
Default value 1rem

Inline-axis padding inside header and body cells.

--cngx-treetable-narrow-cell-padding-inline#*
Default value 0.25rem

Compact inline padding applied to the expand and select utility columns (header + body).

--cngx-treetable-toggle-padding#*
Default value 0.25rem

Padding inside the expand/collapse toggle button.

--cngx-treetable-toggle-radius#<length>
Default value 4px

Corner radius of the expand/collapse toggle button.

State / Empty

--cngx-treetable-empty-padding-block#*
Default value 2rem

Block padding of the empty-state slot.

--cngx-treetable-empty-padding-inline#*
Default value 1rem

Inline padding of the empty-state slot.

--cngx-treetable-empty-font-size#*
Default value 0.875rem

Font-size of the empty-state slot.

Typography

--cngx-treetable-font-size#*
Default value 0.875rem

Default font-size of the table body.

--cngx-treetable-header-font-weight#<number>
Default value 600

Font-weight of header cells.

--cngx-treetable-toggle-font-size#*
Default value 0.875rem

Font-size of the toggle button glyph.

Motion

--cngx-treetable-row-transition-duration#<time>
Default value 120ms

Transition duration of the row background change.

--cngx-treetable-toggle-transition-duration#<time>
Default value 120ms

Transition duration of the toggle button hover state.

Variant / Narrow

--cngx-treetable-narrow-breakpoint#<length>
Default value 960px

Width threshold at which the narrow / mobile overrides apply.

--cngx-treetable-narrow-font-size#*
Default value 0.8125rem

Font-size below the narrow breakpoint.

--cngx-treetable-narrow-header-padding-block#*
Default value 0.5rem

Header cell block padding below the narrow breakpoint.

--cngx-treetable-narrow-header-padding-inline#*
Default value 0.625rem

Header cell inline padding below the narrow breakpoint.

--cngx-treetable-narrow-cell-padding-block#*
Default value 0.875rem

Body cell block padding below the narrow breakpoint -bigger tap target.

--cngx-treetable-narrow-cell-padding-inline-default#*
Default value 0.625rem

Default body cell inline padding below the narrow breakpoint.

--cngx-treetable-narrow-utility-padding-inline#*
Default value 0.125rem

Compact inline padding for utility cells below the narrow breakpoint.

--cngx-treetable-narrow-utility-padding-inline-large#*
Default value 0.875rem

Larger utility inline padding used by the first-data-cell below the narrow breakpoint.

--cngx-treetable-narrow-indent-size#*
Default value 1.25rem

Indent step per depth level below the narrow breakpoint - reduced so deep trees still fit on narrow screens.

--cngx-treetable-narrow-toggle-padding#*
Default value 0.5rem

Toggle button padding below the narrow breakpoint -bigger tap target (≥ 44 px combined).

--cngx-treetable-narrow-toggle-font-size#*
Default value 1rem

Toggle 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());
  }
}