Primitives
Tree
A nested tree view for hierarchical data: expandable nodes with roving-tabindex navigation, single or multi selection, and typeahead.
Walk the tree with the arrow keys: right expands a node and left collapses it. Each node carries data-state, data-selected and data-highlighted.
- src
- app
- app.ts
- app.html
- app.css
- main.ts
- styles.css
- public
- README.md
- package.json
A nested role="tree" → treeitem → group → treeitem widget (file explorers, nav trees, category pickers). Roving-tabindex focus management (DOM focus rides the treeitem), full keyboard interaction, RTL arrow mirroring, and aria-level / aria-setsize / aria-posinset wiring.
Selection and expansion are two independent models: value (selected nodes) and expanded (open nodes). Expansion is always multi; only value honours multiple.
Both models are readonly T[] over the node value type, which ForTree<T = string> infers from [(value)] / [(expanded)]. ForTable uses the same shape for its selected and open rows. Node identity is resolved by compareWith, which defaults to ===; bind [compareWith]="(a, b) => a.id === b.id" when your nodes are objects you re-create (a descendantsOf that maps fresh objects is the case that needs it, because under === a fully-checked subtree reports aria-checked="false").
Anatomy
<ul forTree [(value)]="selected" [(expanded)]="expanded" aria-label="Files">
<li forTreeItem value="src">
<div forTreeItemLabel>
<span forTreeItemToggle>▸</span>
src
</div>
<!-- rendered only while the node is expanded -->
<ul forTreeGroup>
<li forTreeItem value="main.ts">
<div forTreeItemLabel>main.ts</div>
</li>
</ul>
</li>
</ul>
Mounting is the consumer's responsibility: wrap [forTreeGroup] in @if (expanded().includes(node.id)) so a collapsed parent drops its subtree.
Trees are recursive, and the idiomatic Angular shape is a small recursive component for the node. This keeps dependency injection correct at every depth: each node component nests its element injector under its enclosing [forTreeGroup], so [forTreeItem] resolves the right level / container automatically.
Why not
ngTemplateOutlet? A single recursive<ng-template>instantiated with[ngTemplateOutlet]resolves dependency injection from where the template is declared, not where it is inserted. A nested[forTreeItem]would then inject the root tree as its container instead of its enclosing[forTreeGroup], breakingaria-leveland visible-order navigation. A recursive component avoids this. If you must usengTemplateOutlet, pass an explicit[ngTemplateOutletInjector]captured at each insertion point.
In selectionMode="checkbox", place a checkbox surface inside the label:
<div forTreeItemLabel>
<span forTreeItemToggle>▸</span>
<span forTreeItemCheckbox>
<span forTreeItemCheckboxIndicator>✓</span>
</span>
src
</div>
Examples
Cascading checkboxes
selectionMode='checkbox' switches each treeitem from aria-selected to aria-checked and lets every node toggle independently. cascade plus a descendantsOf descriptor propagates checks to all descendants (even collapsed ones) and surfaces aria-checked='mixed' on partially-checked parents.
- Engineering
- Frontend
- Angular
- React
- Vue
- Backend
- Design
- Marketing
Filter picker
Type part of a category name: the tree narrows to the matches, expands their ancestors to reveal them and highlights the matched text, while the cascade checkboxes keep working. The library filters nothing; Filtering is the three-step recipe, with the expandToReveal helper that computes which ancestors to open.
- Engineering
- Frontend
- Backend
- Design
- Marketing
Drag & drop reordering
[forTreeNodeDrag] on the root adds pointer and keyboard reordering and re-parenting; the ⠿ grip is an optional [forTreeNodeDragHandle]. The library never mutates your data, so apply the pure moveTreeNode helper in (nodeDrop). On lift the dragged subtree collapses, which structurally prevents dropping a node into its own descendant.
- Work
- Roadmap.md
- Budget.xlsx
- Designs
- logo.svg
- hero.png
- Personal
- Recipes.md
- Trip.pdf
- Inbox.txt
Virtualized (12,300 nodes)
For huge trees, bind [totalCount] to switch ForTree to the activedescendant model over a consumer-owned virtual window. We flatten the expanded tree to a linear list, feed its length to injectVirtualizer, and render only the visible slice. Each [forTreeItem] gets its absolute [itemIndex] plus level / setSize / posInSet so ARIA stays correct.
Multi select
<ul forTree multiple [(value)]="selected" [(expanded)]="expanded" aria-label="Files">
...
</ul>
In multi mode Space toggles the focused node; Shift+ArrowUp/Down extends; Shift+Space selects the contiguous range from the anchor; Ctrl/Cmd+A selects every visible enabled node (or deselects them when all are already selected, leaving selected disabled or hidden nodes selected).
Checkbox selection
selectionMode="checkbox" switches each treeitem to aria-checked (instead of aria-selected) and makes every node toggle independently, so multiple is not required. Place [forTreeItemCheckbox] and [forTreeItemCheckboxIndicator] inside the label for a visible checkbox surface.
import {
ForTree,
ForTreeGroup,
ForTreeItem,
ForTreeItemCheckbox,
ForTreeItemCheckboxIndicator,
ForTreeItemLabel,
ForTreeItemToggle,
} from 'forty-cdk/tree';
@Component({
selector: 'app-tree-node',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ForTreeItem,
ForTreeItemLabel,
ForTreeItemToggle,
ForTreeGroup,
ForTreeItemCheckbox,
ForTreeItemCheckboxIndicator,
TreeNode,
],
host: { style: 'display: contents' },
template: `
<li forTreeItem [value]="node().id">
<div forTreeItemLabel>
@if (node().children?.length) {
<span forTreeItemToggle>▸</span>
}
<span forTreeItemCheckbox>
<span forTreeItemCheckboxIndicator>✓</span>
</span>
{{ node().name }}
</div>
@if (node().children?.length && expanded().includes(node().id)) {
<ul forTreeGroup>
@for (child of node().children ?? []; track child.id) {
<app-tree-node [node]="child" [expanded]="expanded()" />
}
</ul>
}
</li>
`,
})
export class TreeNode {
readonly node = input.required<Node>();
readonly expanded = input.required<readonly string[]>();
}
@Component({
selector: 'app-categories',
imports: [ForTree, TreeNode],
template: `
<ul
forTree
selectionMode="checkbox"
[(value)]="selected"
[(expanded)]="expanded"
aria-label="Categories"
>
@for (n of roots; track n.id) {
<app-tree-node [node]="n" [expanded]="expanded()" />
}
</ul>
`,
})
export class Categories {
readonly selected = signal<readonly string[]>([]);
readonly expanded = signal<readonly string[]>([]);
readonly roots: Node[] = [
{ id: 'a', name: 'Alpha' },
{ id: 'b', name: 'Beta', children: [{ id: 'b1', name: 'Beta 1' }] },
];
}
Cascade selection
Add cascade and [descendantsOf] to enable tri-state propagation. Checking a parent selects it and all its descendants atomically (including collapsed / unmounted ones), and a parent derives aria-checked="mixed" / data-checked="mixed" when only some descendants are checked. Toggling a parent follows what it shows: one reading checked clears its subtree, and a mixed or unchecked one checks it. A parent's own id in [(value)] tracks its descendants: checking the last unchecked child adds it, and unchecking any child removes it, so a submitted value never carries a partly deselected category. The descendantsOf function must return every selectable descendant id of the given node (not just direct children).
@Component({
selector: 'app-categories',
imports: [ForTree, TreeNode],
template: `
<ul
forTree
selectionMode="checkbox"
cascade
[descendantsOf]="descendantsFn"
[(value)]="selected"
[(expanded)]="expanded"
aria-label="Categories"
>
@for (n of roots; track n.id) {
<app-tree-node [node]="n" [expanded]="expanded()" />
}
</ul>
`,
})
export class Categories {
readonly selected = signal<readonly string[]>([]);
readonly expanded = signal<readonly string[]>([]);
readonly roots: Node[] = [
{
id: 'fruits',
name: 'Fruits',
children: [
{ id: 'apple', name: 'Apple' },
{ id: 'pear', name: 'Pear' },
],
},
];
readonly descendantsFn = (id: string): readonly string[] => {
const flatten = (nodes: Node[]): string[] =>
nodes.flatMap((n) => [n.id, ...flatten(n.children ?? [])]);
const find = (nodes: Node[]): Node | undefined =>
nodes.find((n) => n.id === id) ?? nodes.flatMap((n) => find(n.children ?? [])).find(Boolean);
const node = find(this.roots);
return node?.children ? flatten(node.children) : [];
};
}
Structural nodes
A group header is not an option: it organises the list and is worth navigating to, but there is no checkbox to act on. Set [selectable]="false" on it. The node keeps its place in the hierarchy: it retains aria-level / aria-setsize / aria-posinset and aria-expanded from its toggle, and arrow / Home / End / typeahead reach it. It also drops out of the selection contract: no aria-checked (nor aria-selected in 'highlight' mode), no data-checked, Space and Enter change nothing, and the value never enters [(value)].
It is orthogonal to disabled, which is the wrong lever here: that one announces the node as an unavailable option and takes it out of navigation, so a screen-reader user never learns which group an option belongs to.
Leave [forTreeItemCheckbox] off a structural node. It is the interactive half of the checkbox anatomy, so inside one it paints a box whose click selects nothing, and in dev mode each such click warns FORCDK-TREE-006.
<li forTreeItem value="colors" [selectable]="false">
<div forTreeItemLabel>
<span forTreeItemToggle>▸</span>
<span>Colors</span>
</div>
<!-- rendered only while the node is expanded -->
<ul forTreeGroup>
<li forTreeItem value="red">
<div forTreeItemLabel>
<span forTreeItemCheckbox><span forTreeItemCheckboxIndicator>✓</span></span>
Red
</div>
</li>
</ul>
</li>
Under cascade, a non-selectable node is skipped when an ancestor collects its descendants: it neither enters the checked set nor counts toward that ancestor's 'mixed'. Its own descendants still cascade normally. The tree can only apply that to mounted nodes, so descendantsOf keeps its contract: return the selectable descendant values, leaving out any structural node in a collapsed subtree.
The structural node still derives its own group's roll-up. To show it on the header, read checkState() ('true', 'false' or 'mixed') off the node's forTreeItem export. That gives you a styling hook with no click behind it:
<li forTreeItem #colors="forTreeItem" value="colors" [selectable]="false">
<div forTreeItemLabel [attr.data-rollup]="colors.checkState()">
<span forTreeItemToggle>▸</span>
<span>Colors</span>
</div>
</li>
Filtering
forty-cdk ships no filtering machinery, so matching stays consumer-owned. The library exports one pure helper, expandToReveal, that translates the matched set into the ancestor values you need to expand so every match becomes visible.
Three-step recipe:
- Filter your own data and re-render. Derive a filtered node list with
computed()and drive the tree's@foroff that signal. The library adds no filtering engine, empty-state pieces, or snapshot logic. - Expand ancestors with
expandToReveal. CallexpandToReveal(matches, ancestorsOf)to get the unique ancestor values to merge into[(expanded)]. The helper is pure: it has no Angular reactivity, no DOM, and no side effects. - Highlight matched text with consumer CSS. Wrap matched text in a
<mark>element or apply a.matchclass while rendering filtered labels. No new data attribute is emitted by the library.
import { linkedSignal } from '@angular/core';
import { expandToReveal } from 'forty-cdk/tree';
readonly query = signal('');
readonly filtered = computed(() => filterNodes(this.roots, this.query()));
// Two-way bindable via [(expanded)]: manual expand/collapse is preserved through
// `previous.value`, and a new query re-reveals every match by re-deriving from
// the matched set. `linkedSignal` is the idiomatic replacement for
// `effect(() => this.expanded.update(...))` — no state written inside an effect.
readonly expanded = linkedSignal<readonly string[], readonly string[]>({
source: () => collectIds(this.filtered()),
computation: (matches, previous) => [
...new Set([...(previous?.value ?? []), ...expandToReveal(matches, this.ancestorsOf)]),
],
});
// Returns a node's ancestor ids from the consumer's own hierarchy.
ancestorsOf = (id: string): readonly string[] => { /* walk roots, return the path */ };
expandToReveal accepts any Iterable<T> (array, Set, generator) of node values. Root-level matches contribute nothing, because a root has no ancestors to expand.
Scoped defaults
import { provideForTreeDefaults } from 'forty-cdk/defaults';
// app config or a component's providers
providers: [provideForTreeDefaults({ selectionFollowsFocus: true })];
Virtualization
For very large trees (thousands of nodes) bind [totalCount] to switch to an activedescendant focus model over a consumer-owned virtualized window.
Render that window with injectVirtualizer, not the *forVirtualFor ergonomic layer. *forVirtualFor writes the flat aria-setsize / aria-posinset on each row root on every render, which overwrites the per-level values [forTreeItem] binds from [setSize] / [posInSet] once the window moves.
Opt-in API
ForTree additions
| API | Type | Description |
|---|---|---|
totalCount | input | Total flattened node count. Setting this switches the tree to the activedescendant focus model. Leave unset for roving-tabindex. |
visibleRange | input | Inclusive-exclusive [start, end) index range of the currently rendered nodes. Provided by injectVirtualizer. |
scrollToIndex | output | Emitted when keyboard navigation reaches a node outside the rendered window. Forward to injectVirtualizer's scrollToIndex. |
ForTreeItem additions (virtualized path only)
| API | Type | Description |
|---|---|---|
itemIndex | input | Zero-based absolute index in the flattened node list. Required in the virtualized path. Leave unset (default null) outside the virtualized path. |
level | input | Tree depth of this node (1-based). Overrides the container-derived aria-level in the virtualized path. |
setSize | input | Total siblings at this node's level. Overrides the container-derived aria-setsize in the virtualized path. |
posInSet | input | 1-based position among siblings (matches aria-posinset). Overrides the container-derived value in the virtualized path. |
Naming note: [posInSet] is the per-level aria-posinset (position among siblings at this level, 1-based). It is not the absolute flat index, which is [itemIndex]. This matches the ARIA attribute name and is intentionally different from how some other APIs name it.
Focus-model switch
| Mode | Tree host tabindex | Item tabindex | Focus mechanism | |
|---|---|---|---|---|
Standard (no totalCount) | none | 0 on one item | DOM focus rides the item (roving) | |
Virtualized (totalCount is set) | 0 | -1 always | aria-activedescendant on the host |
Navigation flow
- Consumer flattens their visible tree into a flat list, computing
level,setSize,posInSet, anditemIndexfor each node (using the true sibling totals: off-window siblings contribute their real counts because the consumer knows them). injectVirtualizer({ count: flatCount, estimateSize, scrollElement })drives the render window.- The tree host receives
(scrollToIndex)when keyboard navigation needs a node outside the window; the consumer forwards the index tov.scrollToIndex(idx, { align: 'auto' }). - Once the target node mounts (carrying the requested
[itemIndex]), the bridge effect resolves the pending activedescendant.
Consumer example
import {
ChangeDetectionStrategy,
Component,
ElementRef,
computed,
signal,
viewChild,
} from '@angular/core';
import { ForTree, ForTreeItem, ForTreeItemLabel, ForTreeItemToggle } from 'forty-cdk/tree';
import { injectVirtualizer } from 'forty-cdk/virtualization';
interface TreeNode {
value: string;
label: string;
children?: TreeNode[];
}
interface FlatNode {
value: string;
label: string;
level: number;
setSize: number;
posInSet: number;
itemIndex: number;
expandable: boolean;
}
function flatten(nodes: TreeNode[], expanded: ReadonlySet<string>, level = 1): FlatNode[] {
const result: FlatNode[] = [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]!;
result.push({
value: node.value,
label: node.label,
level,
setSize: nodes.length,
posInSet: i + 1,
itemIndex: result.length,
expandable: !!node.children?.length,
});
if (node.children?.length && expanded.has(node.value)) {
result.push(...flatten(node.children, expanded, level + 1));
}
}
return result;
}
@Component({
selector: 'app-virtual-tree',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ForTree, ForTreeItem, ForTreeItemLabel, ForTreeItemToggle],
template: `
<ul
forTree
#scroll
aria-label="Files"
[(value)]="selected"
[(expanded)]="expanded"
[totalCount]="flat().length"
[visibleRange]="v.range()"
(scrollToIndex)="v.scrollToIndex($event, { align: 'auto' })"
style="overflow: auto; max-height: 400px; position: relative;"
>
<div [style.height.px]="v.totalSize()" style="position: relative">
@for (vi of v.virtualItems(); track vi.key) {
<li
forTreeItem
[value]="flat()[vi.index]!.value"
[level]="flat()[vi.index]!.level"
[setSize]="flat()[vi.index]!.setSize"
[posInSet]="flat()[vi.index]!.posInSet"
[itemIndex]="vi.index"
[style.transform]="'translateY(' + vi.start + 'px)'"
style="position: absolute; left: 0; right: 0;"
>
@if (flat()[vi.index]!.expandable) {
<span forTreeItemToggle>▸</span>
}
<div forTreeItemLabel>{{ flat()[vi.index]!.label }}</div>
</li>
}
</div>
</ul>
`,
})
export class VirtualTree {
readonly selected = signal<readonly string[]>([]);
readonly expanded = signal<readonly string[]>([]);
readonly roots: TreeNode[] = [
/* large tree data */
];
readonly flat = computed(() => flatten(this.roots, new Set(this.expanded())));
private readonly scrollRef = viewChild<ElementRef<HTMLElement>>('scroll');
private readonly scrollElement = computed(() => this.scrollRef()?.nativeElement ?? null);
readonly v = injectVirtualizer({
count: computed(() => this.flat().length),
estimateSize: () => 32,
scrollElement: this.scrollElement,
});
}
Intentional limitations
The following behaviors are unavailable or bounded in the virtualized path and are documented intentional limitations (same as listbox/select virtualization):
- Multi-select range modifiers (Shift+ArrowUp/Down, Shift+Space, Ctrl/Cmd+A) are unsupported: pressing one on a virtualized
[multiple]tree throws in dev mode (a no-op in production) rather than silently degrading. Range selection requires knowing the full list of enabled nodes in the range, which is not available when the list is partially unmounted. UseselectionMode="checkbox"(each node toggles independently, so no range is needed) for multi-select over large trees. - Typeahead reaches only positions the window has rendered at least once. The search runs over the persisted position snapshot rather than the live nodes, so a node the virtualizer has since unmounted is still reachable. The match moves
aria-activedescendantto it and emits(scrollToIndex)so your virtualizer brings it back. A position the window has never rendered carries no text the library can match: the keystroke is consumed, the buffer grows, and nothing moves. The shape that triggers it is a freshly-rendered[totalCount]tree where the user types before scrolling, and a[dataVersion]bump orinvalidateSnapshot()narrows the reachable set back to the current window. Arrow /Home/Endnavigation reaches every position regardless (it walks absolute indices, not text), so that is the workaround for a target the user cannot type their way to; rendering a larger window widens the reachable set. *(expand-all-siblings) is dropped. It requires knowing all siblings at the focused node's level, including those outside the window.
Drag & drop
Add [forTreeNodeDrag] on the same element as [forTree] to enable pointer and keyboard drag reordering and re-parenting.
Pieces
| Class | Selector | Description | |
|---|---|---|---|
ForTreeNodeDrag | [forTreeNodeDrag] | Root coordinator. Apply on the same element as [forTree]. | |
ForTreeNodeDragHandle | [forTreeNodeDragHandle] | Optional grab-area constraint inside an item. When present, pointer drags start only from within it. |
Inputs / outputs
| API | Type | Description |
|---|---|---|
disabled | input | Disables all drag interactions. Default false. |
canDrop | input | Optional veto callback. Return false to reject a specific move. When omitted, all drops are accepted. |
nodeDrop | output | Emitted once per committed move. Apply moveTreeNode in the handler to update your data. |
A non-string tree must bind [canDrop]
ForTreeNodeDrag<T = string> is generic over the same node value type as ForTree. Unlike the root, which infers T from [(value)] / [(expanded)], it has no input that carries T on its own except [canDrop]. So if your node values are not string, bind it, typed at the node value:
readonly canDrop = (event: ForTreeDragDropEvent<FileNode>): boolean => true;
<ul forTree forTreeNodeDrag [(value)]="picked" [canDrop]="canDrop" (nodeDrop)="onDrop($event)"></ul>
A callback that vetoes nothing is enough, because its only job here is to carry the inference.
Read the diagnostic you get without it carefully, because the obvious fix is the wrong one. With no [canDrop], T stays at its string default, so (nodeDrop) reports ForTreeDragDropEvent<string> while the runtime hands you the node value you actually bound. A handler typed at your real node type fails to compile:
TS2345: Argument of type 'ForTreeDragDropEvent<string>' is not assignable to
parameter of type 'ForTreeDragDropEvent<FileNode>'.
The error points at your handler, not at the missing input. Retyping the handler to string to satisfy it is what turns a compile error into a silent one: moveTreeNode then infers its own V as string, your trackBy returns a string id, and the comparison against the object the event really carries never matches, so the helper returns your roots unchanged. The drag completes, the announcement fires, and nothing moves.
Annotating a viewChild / @ViewChild reference (ForTreeNodeDrag<FileNode>) recovers T for reading dropIndicator from TypeScript, but it cannot retype a template binding, so [canDrop] is the only channel that fixes (nodeDrop).
Keyboard interaction
| Key | Behavior while not lifted | Behavior while lifted | |
|---|---|---|---|
Ctrl/Cmd+Space | Lifts the focused node. | — | |
ArrowDown | Normal tree navigation. | Moves the insertion point one row down. | |
ArrowUp | Normal tree navigation. | Moves the insertion point one row up. | |
ArrowRight | Normal expand / enter. | Deepens the target level by 1 (LTR; reversed under RTL). | |
ArrowLeft | Normal collapse / leave. | Shallows the target level by 1 (LTR; reversed under RTL). | |
Space / Enter | Normal select / activate. | Drops the node at the current resolved position. Focus follows the node, or lands on its new parent when that parent is collapsed. | |
Escape | — | Cancels the drag; the node is returned to its original position. | |
Tab | Normal focus leave. | Cancels the drag. |
Minimal example
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
import {
ForTree,
type ForTreeDragDropEvent,
ForTreeGroup,
ForTreeItem,
ForTreeItemLabel,
ForTreeItemToggle,
ForTreeNodeDrag,
ForTreeNodeDragHandle,
moveTreeNode,
} from 'forty-cdk/tree';
interface Node {
id: string;
name: string;
children?: Node[];
}
@Component({
selector: 'app-tree-node',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ForTreeItem,
ForTreeItemLabel,
ForTreeItemToggle,
ForTreeGroup,
ForTreeNodeDragHandle,
TreeNode,
],
host: { style: 'display: contents' },
template: `
<li forTreeItem [value]="node().id">
<div forTreeItemLabel>
<span forTreeNodeDragHandle aria-hidden="true">⠿</span>
@if (node().children?.length) {
<span forTreeItemToggle>▸</span>
}
{{ node().name }}
</div>
@if (node().children?.length && expanded().includes(node().id)) {
<ul forTreeGroup>
@for (child of node().children ?? []; track child.id) {
<app-tree-node [node]="child" [expanded]="expanded()" />
}
</ul>
}
</li>
`,
})
export class TreeNode {
readonly node = input.required<Node>();
readonly expanded = input.required<readonly string[]>();
}
@Component({
selector: 'app-files',
imports: [ForTree, ForTreeNodeDrag, TreeNode],
template: `
<ul
forTree
forTreeNodeDrag
[(value)]="selected"
[(expanded)]="expanded"
[canDrop]="canDrop"
(nodeDrop)="onDrop($event)"
aria-label="File system"
>
@for (n of roots(); track n.id) {
<app-tree-node [node]="n" [expanded]="expanded()" />
}
</ul>
`,
})
export class Files {
readonly selected = signal<readonly string[]>([]);
readonly expanded = signal<readonly string[]>([]);
readonly roots = signal<Node[]>([
{
id: 'documents',
name: 'Documents',
children: [
{ id: 'resume', name: 'Resume' },
{ id: 'projects', name: 'Projects', children: [{ id: 'alpha', name: 'Alpha' }] },
],
},
{ id: 'readme', name: 'Readme' },
]);
readonly canDrop = (event: ForTreeDragDropEvent): boolean => {
return event.newParent !== event.node;
};
onDrop(event: ForTreeDragDropEvent): void {
this.roots.update((r) =>
moveTreeNode(r, {
event,
trackBy: (n) => n.id,
children: (n) => n.children,
withChildren: (n, children) => ({ ...n, children: children as Node[] }),
}),
);
}
}
Data attributes on [forTreeNodeDrag]
| Attribute | Values | When present | |
|---|---|---|---|
data-dragging | "" (empty) | A drag session is live (pointer or keyboard). | |
data-drop-target | "" (empty) | A valid drop target has been resolved. | |
--for-tree-drop-level | integer 1–N | The resolved depth of the current target. |
Drop indicator
While a drag is live, the single [forTreeItem] the lifted node would land beside reflects data-drop-position so you can draw an indented insertion line. Exactly one visible item carries it at a time; it is absent on every other row and whenever the tree is idle. It tracks every pointer move and every Arrow step (in both LTR and RTL) and clears on drop / cancel / Escape / Tab.
| Piece | Attribute | Values | |
|---|---|---|---|
[forTreeItem] | data-drop-position | "before" | "after" (the line sits above / below the row) |
Pair it with the root's --for-tree-drop-level (the resolved depth) to indent the line to the target level:
[forTreeItem][data-drop-position]::after {
content: '';
position: absolute;
left: calc(var(--for-tree-drop-level, 1) * 1rem);
right: 0;
height: 2px;
background: var(--accent);
}
[forTreeItem][data-drop-position='before']::after {
top: 0;
}
[forTreeItem][data-drop-position='after']::after {
bottom: 0;
}
Advanced consumers can read the same resolved position programmatically: [forTreeNodeDrag] exposes a read-only dropIndicator: Signal<ForTreeDropIndicator<T> | null> ({ anchor, position, level }, null when idle). Injecting it through ForTreeNodeDragContext reads the same signal at ForTreeDropIndicator<unknown>, since a token cannot carry the node value type.
On lift the dragged node's subtree is collapsed (and restored on drop / cancel). This keeps the drop geometry tractable and structurally prevents dropping a node into its own descendant; [canDrop] adds consumer-defined vetoes on top.
Localizing drag announcements
While a drag is in flight, [forTreeNodeDrag] announces lift / move / drop / cancel / invalid-drop through an off-screen live region. The phrasing is English by default; override it per injector scope with provideForTreeDefaults so screen readers speak the consumer's language. position / total are 1-based, and parentLabel is null when the node lands at the root. Phrase the root-vs-parent distinction in your own language.
The label and parentLabel a formatter receives are the node's [textValue] when it carries one, and the accessible text of its [forTreeItemLabel] otherwise. That is the same text typeahead matches against, so a node is announced by the name the user types to reach it, and aria-hidden decoration inside the label (a toggle caret, a checkbox glyph) is excluded from both.
import { provideForTreeDefaults } from 'forty-cdk/defaults';
provideForTreeDefaults({
dragAnnounceLift: (label) =>
`${label} levantado. Usa las flechas para mover, Espacio para soltar, Escape para cancelar.`,
dragAnnounceMove: (label, parentLabel, position, total) =>
`${label}: ${parentLabel ? `dentro de ${parentLabel}, ` : 'en la raíz, '}posición ${position} de ${total}.`,
dragAnnounceDrop: (label, parentLabel, position, total) =>
`${label} soltado ${parentLabel ? `dentro de ${parentLabel}, ` : 'en la raíz, '}posición ${position} de ${total}.`,
dragAnnounceCancel: (label) => `Cancelado. ${label} vuelve a su posición original.`,
dragAnnounceInvalid: (label) => `No se puede soltar ${label} aquí.`,
});
| Default | Type | Description |
|---|---|---|
dragAnnounceLift | (label: string) => string | Announced when a node is picked up for drag. |
dragAnnounceMove | (label: string, parentLabel: string | null, position: number, total: number) => string | Announced on each intermediate move while a node is lifted. |
dragAnnounceDrop | (label: string, parentLabel: string | null, position: number, total: number) => string | Announced when a node is committed to its new position. |
dragAnnounceCancel | (label: string) => string | Announced when a lift is cancelled and the node returns to origin. |
dragAnnounceInvalid | (label: string) => string | Announced when a canDrop veto rejects the attempted drop. |
API
ForTree
| Property | Type | Description |
|---|---|---|
value | model | Two-way bindable. Selected node values. Single mode keeps 0 or 1; multi any number. Default: [] |
expanded | model | Two-way bindable. Open (expanded) parent node values. Always multi. Default: [] |
selected | Signal | Read-only single-select convenience view of value: the sole selected value, or null when none / many are selected.Default: — |
compareWith | input | Equality comparator for node values. Selection and expansion membership, cascade descendants, the range anchor, and drag-drop resolution all route through it. Default: (a, b) => a === b |
multiple | input | When true, multiple nodes can be selected. Default: false |
disabled | input | Disables the whole tree. Reflected as aria-disabled / data-disabled.Default: — |
orientation | input | Navigation axis. 'vertical' (ArrowUp/Down move; ArrowLeft/Right expand/collapse). Reflected as aria-orientation / data-orientation.Default: 'vertical' |
ariaLabel | input | Reactive accessible name, reflected as aria-label. Prefer native aria-labelledby when a visible label exists.Default: null (and empty) emits no attribute |
dir | input | Writing direction. null resolves the inherited ambient direction; an explicit value wins. Reflected to the host dir attribute and mirrors the expand/collapse arrows in RTL.Default: null |
selectionFollowsFocus | input | Single-mode only. When true, every keyboard focus move (arrow navigation, entering a child, leaving to the parent, a typeahead match) also selects the focused node. Default: from provideForTreeDefaults |
selectionMode | input | Selection presentation. 'highlight' uses aria-selected; 'checkbox' uses aria-checked and renders the checkbox anatomy (inherently multi-select).Default: 'highlight' |
cascade | input | Enables cascade selection in selectionMode="checkbox": checking / unchecking a node propagates to all descendants, and a parent reports aria-checked="mixed" when only some descendants are checked. Requires descendantsOf.Default: false |
descendantsOf | input | Returns the selectable descendant values of a node (excluding the node itself). Required when cascade is true; the tree throws a [forty-cdk/tree] error otherwise.Default: — |
itemActivate | output | Emitted once per press on a node, before the selection is applied: a click on its [forTreeItemLabel] or [forTreeItemCheckbox], or Enter / Space on the focused node, virtualized or not. Carries the node value and the click / keydown as event, so its modifier keys are readable. preventDefault() skips the selection, so [(value)] is unchanged and (valueChange) does not fire, while focus still moves to the node. It fires on a [selectable]="false" node too, where there is no selection to veto, and never on a disabled node.Default: — |
| Data attribute | Values | |
|---|---|---|
data-orientation | vertical | horizontal | |
data-disabled | present | absent |
ForTreeItem
| Property | Type | Description |
|---|---|---|
value | input.required | The node's value. Must be unique within the tree. Default: — |
disabled | input | Disables this node: not selectable, skipped by keyboard navigation. Default: — |
selectable | input | Whether the node takes part in selection. false marks a structural node: it keeps navigation, typeahead and its ARIA position, but emits no selection state and never enters [(value)].Default: true |
textValue | input | Text override for the node's name, used by typeahead matching and by the drag announcements. Falls back to the [forTreeItemLabel]'s accessible text when empty, which excludes any aria-hidden subtree, so a nested [forTreeItemToggle] or [forTreeItemCheckbox] glyph contributes nothing.Default: — |
| Data attribute | Values | |
|---|---|---|
data-state | open | closed (parent items only) | |
data-selected | present | absent | |
data-highlighted | present | absent | |
data-disabled | present | absent | |
data-checked | "true" | "false" | "mixed" (checkbox mode only) |
A [forTreeItem] emits data-state only when it is a parent (a [forTreeItemToggle] is registered inside it); leaves carry neither data-state nor aria-expanded. Expansion (data-state) and selection (data-selected) are independent hooks because a node can be both expandable and selected at once.
ForTreeItemToggle
| Data attribute | Values | |
|---|---|---|
data-state | open | closed |
ForTreeItemCheckbox
| Data attribute | Values | |
|---|---|---|
data-state | checked | unchecked | indeterminate | |
data-disabled | present | absent |
data-disabled mirrors the node's disabled state, whether it comes from the item's own [disabled] or the root's, so the box can share one disabled style with a standalone [forCheckbox].
ForTreeItemCheckboxIndicator
| Data attribute | Values | |
|---|---|---|
data-state | checked | unchecked | indeterminate |
Keyboard
Vertical, LTR (mirrored for dir="rtl"):
| Key | Behavior | |
|---|---|---|
| ArrowDown / ArrowUp | Move focus to the next / previous visible node (no wrap; collapsed subtrees are skipped). | |
| ArrowRight | Closed parent → expand (focus stays); open parent → focus first child; leaf → no-op. | |
| ArrowLeft | Open parent → collapse (focus stays); otherwise → focus the parent node; closed root → no-op. | |
| Home / End | First / last visible node. | |
| Enter | Activate the focused node: emits (itemActivate), then selects it unless vetoed. | |
| Space | Activate like Enter. Single: select. Multi: toggle the focused node's selection. | |
| * | Expand every sibling parent at the focused node's level. | |
| type a character | Typeahead: focus the next visible node whose label starts with the buffer. | |
| Shift+ArrowUp/Down | Multi: move focus and toggle the new node's selection. | |
| Shift+Space | Multi: select the contiguous range from the anchor to the focused node. | |
| Ctrl/Cmd+A | Multi: select every visible enabled node (deselects them when all are already selected). |
Under dir="rtl" the expand / collapse arrows swap: ArrowLeft expands and ArrowRight collapses.
Accessibility
Implements the WAI-ARIA Tree View pattern (APG Approach A, where DOM focus rides the treeitem).
- Label the tree via the reactive
[ariaLabel]input or a nativearia-labelledbypointing at a visible heading. data-state="open" | "closed"is reflected on parent nodes only (and on the toggle); leaves carry neither, matchingaria-expanded.data-selected(present / absent) reflects selection on every node. A node is simultaneously expandable and selectable, so expansion (data-state) and selection (data-selected) get separate hooks.data-highlighted=""marks the current roving-tabindex node, the same hook used across the listbox / menu / select primitives.- Exactly one node is tabbable at a time (the selected node, or the first enabled node).
Tabenters and leaves the whole tree in one stop. - The tab stop stays where the user was. When the active node is removed, collapsed away (through a toggle, the keyboard or a write to
[(expanded)]) or disabled, the tab stop moves to the nearest visible enabled node before it, which is the collapsed ancestor on a collapse. If the node held focus when it left the DOM, focus moves there too; focus on a control outside the tree is left alone. A node disabled while it holds focus keeps the arrow keys, Home and End. - A
[selectable]="false"node emits neitheraria-checkednoraria-selected(and neitherdata-checkednordata-selected), so assistive tech announces a group header as a heading in the hierarchy rather than as an option the user can act on. It carries noaria-disabledand keeps its tab stop, itsaria-level/aria-setsize/aria-posinsetand itsaria-expanded. - In
selectionMode="checkbox"eachtreeitememitsaria-checked("true"/"false") and noaria-selected; the[forTreeItemCheckbox]and[forTreeItemCheckboxIndicator]arearia-hidden/ decorative, because thetreeitemitself is the accessible checkbox. Withcascade, a parent reportsaria-checked="mixed"(anddata-checked="mixed") when only some of its descendants are checked; the cascade reaches collapsed / unmounted descendants through thedescendantsOfdescriptor, so the tri-state is always correct even when children are not yet mounted.
Styling
forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under API, not off the for* selectors (Styling forty-cdk explains why).
.tree-toggle {
display: inline-block;
transition: transform 150ms;
}
.tree-toggle[data-state='open'] {
transform: rotate(90deg);
}
.tree-item[data-highlighted] {
outline: 2px solid Highlight;
}
Wrapping in a design system
Subclass the root and re-provide both of its tokens with useExisting pointing at the subclass, since Angular does not inherit a directive's providers: FOR_TREE_CONTEXT, and FOR_TREE_CONTAINER_CONTEXT, which every root-level [forTreeItem] registers through. Leaving out the second one throws FORCDK-TREE-002 from the first root-level item. Wrapping non-form roots walks the pattern.
import { Directive } from '@angular/core';
import { FOR_TREE_CONTAINER_CONTEXT, FOR_TREE_CONTEXT, ForTree } from 'forty-cdk/tree';
@Directive({
selector: '[myTree]',
exportAs: 'myTree',
providers: [
{ provide: FOR_TREE_CONTEXT, useExisting: MyTree },
{ provide: FOR_TREE_CONTAINER_CONTEXT, useExisting: MyTree },
],
})
export class MyTree<T = string> extends ForTree<T> {}
To read the tree from a piece of your own, inject the token with the node value type: inject<ForTreeContext<string>>(FOR_TREE_CONTEXT). Without the type argument every node value reads as unknown.