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 (from @cngx/utils). 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 seeded from the first non-empty flatNodes. The seed fires once per component - later tree swaps never re-expand a collapsed grid.
  • Selection state via the selectedIds model, reconciled against selectionMode changes ('none' clears; 'single' truncates).
  • Id hygiene across tree swaps: expansion and selection ids absent from the new flatNodes are 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: focusedNodeId tracks the last-focused row, effectiveFocusedId reconciles it against the visible rows to keep exactly one row tab stop, and keyboard navigation moves real DOM focus (document.activeElement follows).
  • 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).

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>

https://cngxjs.github.io/cngx/examples/#/data-display/treetable/base/single-select https://cngxjs.github.io/cngx/examples/#/data-display/treetable/base/multi-select-checkboxes https://cngxjs.github.io/cngx/examples/#/data-display/treetable/base/controlled-expansion https://cngxjs.github.io/cngx/examples/#/data-display/treetable/async/async-state-lifecycle https://cngxjs.github.io/cngx/examples/#/data-display/treetable/slots/custom-cell-and-empty https://cngxjs.github.io/cngx/examples/#/data-display/treetable/data/sort-and-search https://cngxjs.github.io/cngx/examples/#/data-display/treetable/data/header-click-sort https://cngxjs.github.io/cngx/examples/#/data-display/treetable/data/multi-column-sort

Metadata#

Content Slots#

Host#

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 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" />
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, and ids that vanish from the tree after a swap are pruned (with a change emit).

<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
skeletonRowCount#number
input()

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

default 3

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" />

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 CngxTreetableNode 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 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" />

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

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

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.

retry#void
output()

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.

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, and ids that vanish from the tree after a swap are pruned (with a change emit).

<cngx-treetable [(selectedIds)]="myIds" />

Instance Properties#

focusedNodeId#string | null
signal()Readonly

Logical 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)
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<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);
    },
  },
)
selectionAnnouncement#unknown
ProtectedReadonly

SR 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()
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. 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)
@parameventKeyboardEvent
handleRowClick#void
handleRowClick(node: CngxTreetableFlatNode<T>)

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

toggleAll#void

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

BindingExpression
[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 by highlight() && 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().

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.

State / Loading

--cngx-treetable-skeleton-line-size#*
Default value 12px

Height of one skeleton placeholder line during a first load.

Motion

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

Transition 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

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

Transition 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

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

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

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