Skip to main content

tree-node — one row of a tree

UiTreeNode is a single row inside a tree — and, because it is a full UiNode with its own id rather than a nested value object, an individually addressable one. It carries a label, an icon, an optional click trigger, nested children, and — the interesting part — a labelNode and a content slot that each take any other node.

That is what turns a tree from a list of strings into a real component: a row's title can be a stack with a badge and a context menu, and an expanded row can hold a detail panel, a form or a chart.

Live — a tree node cannot render on its own, so this is a small tree showing one node's features. Row 2's labelNode is a stack holding a menu-button (open the "⋮" menu); row 3 expands to reveal a detail node in its content.

Fields

FieldTypeMeaning
idStringNode id — the DOM id, the data-id, and the patch target for this row.
labelStringPlain-text label. Stays the accessible fallback when labelNode is set.
labelNodeUiNodeRich row title rendered instead of the label text. Any node.
iconStringLeading icon token shown before the label. See icons.
onClickUiTriggerFired when the label is clicked. null = static, non-clickable label.
contentUiNodeArbitrary body rendered inside the row when expanded, above the children.
childrenList<UiTreeNode>Child rows. Defaults to an empty list.
openbooleanInitial expanded state. Defaults to false. User toggles win afterwards.
selectedbooleanDefaults to false. true renders the row highlighted.
titleStringInherited from UiNode; unused by this renderer — use label.
cssClassStringExtra CSS class on the <li>.

A node with children or content is expandable and renders as a native <details> disclosure. A node with neither is a leaf and renders as a plain row.

Building one

// A leaf row: icon, label, click.
UiTreeNode.of("t-app", "app.ts").icon("document")
.onClick(UiTrigger.go("/files/app.ts"));

// href(…) is shorthand for .onClick(UiTrigger.go(…)).
UiTreeNode.of("t-pom", "pom.xml").icon("document").href("/files/pom.xml");

// A rich label: the row title is a stack of the name plus a per-row context menu.
UiTreeNode.of("cf-report", "report.pdf").icon("document")
.labelNode(UiStack.of(
UiText.of("report.pdf"),
UiMenuButton.of("cf-report-menu",
UiMenuItem.of("cf-report-ren", "Rename").icon("edit")
.onClick(UiTrigger.toast("Rename report.pdf")),
UiMenuItem.of("cf-report-dl", "Download").icon("download")
.onClick(UiTrigger.toast("Downloading report.pdf")),
UiMenuItem.divider(),
UiMenuItem.of("cf-report-del", "Delete").icon("delete").danger(true)
.onClick(UiTrigger.toast(UiToast.error("Deleted report.pdf")))
)).direction(UiStack.Direction.HORIZONTAL).gap(8));

// A rich body: the expanded row shows a detail panel, then its children.
UiTreeNode.of("r-order", "Order #1024").icon("document").open(true)
.content(UiDetail.of("r-order-detail", null)
.field(UiField.text("r-cust", "Customer", "Grace Hopper"))
.field(UiField.number("r-total", "Total", 249.0)))
.child(UiTreeNode.of("r-line-1", "1 × Keyboard").icon("document"));

Notes

labelNode takes any node — that is the escape hatch. The row title is rendered through the same renderer registry as everything else, so a stack, a badge, a progress bar or a menu-button all work. A per-row context menu is exactly this: labelNode = stack of the name plus a menu-button aligned to END. Keep label filled in as the plain-text fallback.

content vs. children. children are more tree rows; content is one arbitrary component rendered above them inside the disclosure. Both make the node expandable, and a node can have both. Use content when a row needs a detail panel or a chart rather than more hierarchy.

Patch one row, not the tree. Because each node has its own DOM id, a REPLACE patch on that id re-renders just that row and its subtree, and REMOVE drops it. This is how you lazily load children on click: the onClick trigger returns a patch that replaces the clicked node with a version that has children filled in.

open is a starting value only. The disclosure carries data-sui-client-collapse, so the user's manual expand/collapse survives re-renders and streaming patches. Sending a new open value in a patch will not force a row open once the user has touched it.

See also