Related: Components index · Frontend Architecture · Translations · Testing
ll-tree renders a hierarchical, keyboard-accessible tree. It lives in
src/app/shared/components/tree/ and is built on the headless
@angular/aria/tree primitives, styled with
Tailwind against the app's theme tokens so it reads as a sibling of the
Spartan/Helm components in libs/ui.
It is deliberately not in libs/ui. That directory is the vendored Spartan Helm
layer — components.json points componentsPath there and every folder is aliased
@spartan-ng/helm/* in tsconfig.json. Spartan ships no tree primitive today; if it
ever does, spartan-ng add tree must not collide with this component.
<ll-tree
[nodes]="tree()"
[expandedKeys]="expandedKeys()"
(expandedKeysChange)="onExpandedKeysChange($event)"
[selectedKey]="selectedId()"
[guides]="true"
(nodeSelect)="onNodeSelect($event)">
<ng-template llTreeNodeDef let-node>
<span class="break-all">{{ node.name }}</span>
</ng-template>
</ll-tree>import { collectGroupKeys, LlTreeImports } from '@shared/components/tree/tree.imports';
@Component({ imports: [LlTreeImports] /* ... */ })ll-tree renders the row chrome and the chevron; everything inside the row comes from
your llTreeNodeDef template. Expansion state is never written onto your nodes, so the
node array can be a computed() that rebuilds freely.
| Member | Type | Default | Notes |
|---|---|---|---|
nodes |
input.required<readonly T[]> |
— | The tree to render. |
expandedKeys |
model<ReadonlySet<string>> |
empty set | Keys of expanded nodes. Uncontrolled by default; bind it to drive expansion yourself. |
selectedKey |
model<string | undefined> |
undefined |
Key of the selected node. |
indent |
input<number> |
16 |
Pixels of indentation per level. |
guides |
input<boolean> |
false |
Draw vertical indent guide lines. |
nodeSelect |
output<T> |
— | Emitted when a leaf is selected. |
Marks the ng-template rendering a node's content. Context:
| Property | Type |
|---|---|
$implicit |
T — the node |
expanded |
boolean |
hasChildren |
boolean |
level |
number — 1-based |
interface LlTreeNode {
key: string; // stable, unique — drives tracking, expansion and selection
name: string; // typeahead label only; ll-tree never renders it
children?: LlTreeNode[];
}
function collectGroupKeys(nodes: readonly LlTreeNode[]): ReadonlySet<string>;collectGroupKeys returns every key that has children, at any depth — useful for
"expand all".
- Group nodes are not selectable. A node with children toggles expansion on click
or Space/Enter; only leaves select and emit
nodeSelect. selectionModeisexplicit. Arrow keys move focus without changing selection; Space/Enter selects. This keeps a detail pane from re-rendering on every keypress.- Keyboard support comes from Aria: arrows, Home/End, expand/collapse, and
typeahead (which uses
node.name, not the row's rendered text). - Collapsed subtrees are unmounted, not hidden. Aria's
TreeItemGrouphost-appliesDeferredContent, so collapsed branches cost nothing. There is no virtual scrolling, so a fully expanded very large tree still renders every visible node.
@angular/aria is developer preview (@developerPreview 21.0 on every directive),
so its API may break in a minor release. It is confined to tree.ts — consumers only
see the ll-tree API, so an upstream break is a one-file repair. The version is pinned
exactly (21.2.5) because the package peer-depends on an exact @angular/cdk version;
aria, cdk and material must be upgraded together.
The published guide at angular.dev contradicts the installed package in two places.
Trust node_modules/@angular/aria/types/tree.d.ts, and do not "fix" working code to
match the docs:
| Topic | angular.dev says | The package actually does |
|---|---|---|
| Selection | [(value)], a single value |
values: V[] with output valuesChange — an array, even in single-select |
| Group markup | <ul role="group"> is a sibling after </li> |
Nested inside the <li ngTreeItem> (verified working) |
Tree items nest — a child's <li> lives inside its parent's <li> — and Tailwind's
group-* variants compile to a descendant selector
(:is(:where(.group\/item):focus-visible *)). An ancestor's state therefore styles
every descendant row. This shipped as a real bug twice over: focusing a parent drew a
focus ring around all of its children, and expanding a node rotated every descendant
chevron, collapsed ones included.
Per-item state lives in the component's styles block, scoped with the child
combinator:
[data-slot='tree-item']:focus-visible > [data-slot='tree-item-row'] { … }
[data-slot='tree-item'][aria-selected='true'] > [data-slot='tree-item-row'] { … }Scoped styles work here — unlike the <svg> pointer-events rule further down (in the
notes on writing tests) — because both the <li> and the row are in ll-tree's own
template and so carry its _ngcontent attribute.
The chevron rotation uses transform: rotate(90deg) rather than the rotate property,
because the chevron's transition-transform animates transform.
Rows are packed tight: 2px between siblings, and exactly 0 between a parent's row and
its child group, because the group is a block sibling of the row inside the same <li>.
An outward ring (box-shadow: 0 0 0 3px) therefore always spills onto the neighbouring
row — and since that neighbour's background paints later, it clips the spilled band.
The visible symptom was a focused parent whose ring collided with, and was eaten by, its
selected child's highlight.
So the ring is inset, and must stay that way:
box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--ring) 60%, transparent);This deviates from the Helm convention (focus-visible:ring-[3px], which is outset) on
purpose: Helm applies it to buttons and inputs, which have room around them. A tree row
does not. tree.spec.ts asserts the declared rule is inset, because happy-dom neither
resolves :focus-visible nor paints, so nothing else in the suite would catch a revert.
A row is offset by (level - 1) × indent pixels of margin-left, and nothing else
may offset a node. Margin rather than padding so the indent sits outside the row's
box: the selected/hover highlight then wraps only the node itself instead of the whole
line — which matters because a prefix row is a synthesised namespace segment, not a
translation record, so a full-width bar would misrepresent it.
In particular a child <ul role="group"> must never carry a margin-left or
border-left: because each group is nested inside its parent's <li>, such an offset
accumulates down the tree and stacks on top of the row's own offset, so levels drift
outward instead of stepping evenly. This was a real bug — with guides enabled, levels
landed at 0 / 32 / 80px instead of 0 / 16 / 32px.
That is why indent guides are drawn as an absolutely-positioned ::before pseudo-element
on the group, offset by the --ll-tree-guide-left custom property: it costs no layout.
tree.spec.ts guards both halves — indentation is asserted across three levels with
guides on and off, and child groups are asserted to have no margin.
Two more things worth knowing before writing tests, both learned the hard way:
- Aria binds
pointerdown, notclick.HTMLElement.click()does nothing; dispatchnew MouseEvent('pointerdown', { bubbles: true }). - Icons in a row must not be hit-testable. Aria resolves the pressed item with
event.target instanceof HTMLElement. An<svg>is anSVGElement, so pressing an icon — the chevron, or any icon your node template renders — is silently ignored. The row therefore carries[&_svg]:pointer-events-none, which retargets the press to the row. Do not remove it, and do not move the rule into the component'sstylesblock: Angular's emulated encapsulation would scope it tosvg[_ngcontent-…], and an<svg>rendered byng-iconbelongs to that component's view, so the rule would never match. It has to be a global utility class. Keying onsvgrather thanng-icon(ashlm-buttondoes) leaves the icon host hoverable, so tooltips on projected icons keep working. TreeItempublishes expansion from anafterRenderEffect, whichfixture.detectChanges()does not flush. Tests mustawait fixture.whenStable()or children never mount. See thesettle()helper intree.spec.ts.
The translations tree layout
(src/app/features/spaces/translations/shared/components/translation-list/) is the
reference usage. It demonstrates the controlled-expansion pattern: while a filter is
active the tree opens fully — via collectGroupKeys — so no match hides behind a
collapsed parent, and the user's manual expansion is restored when the filter clears.