Tree
A hierarchy a reader can open, walk with the keyboard and select from. The nodes are the app's own objects: the tree asks what identifies one, what it looks like, and what lies below it. Expansion and selection are the page's or the tree's, one axis at a time — and a tree with thousands of nodes renders only the rows on screen.
One focusable element per tree: arrows move a cursor inside it, Right and Left open and climb, typing jumps, and Enter selects. None of that is JavaScript — the kit ships none.
using Rask.Ui;
namespace Rask.Site.Features.UiKit;
/// <summary>
/// The kit's tree: what it takes, who holds its state, and what it does with a large one.
/// </summary>
/// <remarks>
/// Three trees over the same shape of data. The first holds its own state, the second hands both axes to this
/// component, and the third is 5,000 nodes with only the visible rows in the DOM.
/// </remarks>
public sealed partial class UiKitTreeDemo : Component
{
private static readonly TreeNode[] Files =
[
new("src", "src", [
new("src/app", "app", [new("src/app/Program.cs", "Program.cs"), new("src/app/App.cs", "App.cs")]),
new("src/ui", "ui", [new("src/ui/Button.cs", "Button.cs"), new("src/ui/Card.cs", "Card.cs")]),
]),
new("docs", "docs", [new("docs/tree.md", "tree.md"), new("docs/ui-kit.md", "ui-kit.md")]),
new("README.md", "README.md"),
];
private static readonly TreeNode[] Many = BuildMany();
private IReadOnlyList<string> _open = ["src"];
private IReadOnlyList<string> _picked = [];
private string? _hovered;
/// <inheritdoc />
protected override Component? Render() =>
[
Section(
"A tree",
"Four steps and an indexer: where it starts, what identifies a node, what a node looks like, what the "
+ "control is called — and, in the brackets, what lies below a node. This one remembers what is open and "
+ "what is selected, and ExpandDepth(1) opens the roots to start with. Click a row, or focus the tree and "
+ "press the arrow keys; typing a letter jumps.",
Div.Data(Testid("ui-tree-basic")).Class("max-w-sm rounded-xl border border-base-300 bg-base-100 p-2")[
UiTree.Roots(Files)
.NodeKey(f => f.Id)
.Item(f => Span[f.Name])
.Label("Files")
.ExpandDepth(1)
.NodeText(f => f.Name)
.Selection(UiTreeSelection.Single)[f => f.Children]
]),
Section(
"Held by the page",
"The same tree with both axes on this component: Expanded and Selected are fields here, and the tree "
+ "reports every change. Either axis can be the page's on its own — this one takes both so the state is "
+ "visible below.",
Div.Data(Testid("ui-tree-controlled")).Class("max-w-sm space-y-2")[
Div.Class("rounded-xl border border-base-300 bg-base-100 p-2")[
UiTree.Roots(Files)
.NodeKey(f => f.Id)
.Item(f => Span[f.Name])
.Label("Files, held by the page")
.Expanded(_open)
.OnExpandedChange(keys => _open = keys)
.Selection(UiTreeSelection.Multiple)
.Selected(_picked)
.OnSelectionChange(keys => _picked = keys)[f => f.Children]
],
P.Data(Testid("ui-tree-controlled-state")).Class("text-xs text-ui-muted")[
$"open: {string.Join(", ", _open)} · selected: "
+ (_picked.Count == 0 ? "none" : string.Join(", ", _picked))
]
]),
Section(
"Five thousand nodes",
"ItemSize turns the tree into a virtualized flat list: one row's height, only the visible rows in the "
+ "DOM, and the levels carried by aria-level rather than by nesting. OnHover reports the node under the "
+ "pointer.",
Div.Data(Testid("ui-tree-virtual")).Class("max-w-sm space-y-2")[
Div.Class("rounded-xl border border-base-300 bg-base-100 p-2")[
UiTree.Roots(Many)
.NodeKey(n => n.Id)
.Item(n => Span[n.Name])
.Label("Generated nodes")
.ExpandDepth(1)
.ItemSize(28)
.Height(320)
.OnHover(n => _hovered = n?.Name)[n => n.Children]
],
P.Data(Testid("ui-tree-hover-state")).Class("text-xs text-ui-muted")[
_hovered is null ? "hovering: nothing" : $"hovering: {_hovered}"
]
])
];
// 100 branches of 50, so the flat list is long enough for the window to matter.
private static TreeNode[] BuildMany()
{
var roots = new TreeNode[100];
for (var i = 0; i < roots.Length; i++)
{
var children = new TreeNode[50];
for (var j = 0; j < children.Length; j++)
{
children[j] = new TreeNode($"n-{i}-{j}", $"node {i}.{j}");
}
roots[i] = new TreeNode($"n-{i}", $"branch {i}", children);
}
return roots;
}
private static Dictionary<string, string?> Testid(string value) => new() { ["testid"] = value };
private static Component Section(string heading, string blurb, Component body) =>
Div.Key(heading).Class("mb-8")[
H2.Class("text-lg font-semibold tracking-tight")[heading],
P.Class("mt-1 mb-3 text-sm text-ui-muted")[blurb],
body
];
private sealed record TreeNode(string Id, string Name, TreeNode[]? Children = null);
}
A tree
Four steps and an indexer: where it starts, what identifies a node, what a node looks like, what the control is called — and, in the brackets, what lies below a node. This one remembers what is open and what is selected, and ExpandDepth(1) opens the roots to start with. Click a row, or focus the tree and press the arrow keys; typing a letter jumps.
Held by the page
The same tree with both axes on this component: Expanded and Selected are fields here, and the tree reports every change. Either axis can be the page's on its own — this one takes both so the state is visible below.
open: src · selected: none
Five thousand nodes
ItemSize turns the tree into a virtualized flat list: one row's height, only the visible rows in the DOM, and the levels carried by aria-level rather than by nesting. OnHover reports the node under the pointer.
hovering: nothing