Tree View
A hierarchical list — file explorers, navigation, nested settings — with expand / collapse, single, multiple and checkbox selection, lazy loading and typeahead.
File explorer
The WAI-ARIA Tree View pattern. Click a row to select it (@bind-SelectedValue) or
the chevron (BitBhTreeItemToggle) to expand without selecting. Keyboard: Up / Down move,
Right expands or enters a folder, Left collapses or returns to the parent, Home / End jump,
* expands every sibling, Enter (or double click) fires OnItemActivate,
and typing a name jumps to it (typeahead, matched against Label).
aria-level, aria-setsize, aria-posinset and the roving
tabindex are computed for you; disabled items are skipped.
- src
- components
- Button.razor
- Tree.razor
- services
- ApiClient.cs
- AuthService.cs
- Program.cs
- wwwroot
- app.css
- favicon.png
- secrets.json
- README.md
Selected: src/components/Tree.razor · Last activated: —
<BitBhTree TValue="string" aria-label="Project files" class="tree"
@bind-SelectedValue="selectedFile"
DefaultExpandedValues="@(new[] { "src/", "src/components/" })"
OnItemActivate="v => openedFile = v">
<BitBhTreeItem TValue="string" Value="@("src/")" Label="src" class="tree-item">
<Template Context="srcDir">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">@(srcDir.IsExpanded ? "📂" : "📁")</span>
<span>src</span>
</div>
<BitBhTreeGroup TValue="string" class="tree-group">
<BitBhTreeItem TValue="string" Value="@("src/components/")" Label="components" class="tree-item">
<Template Context="componentsDir">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">@(componentsDir.IsExpanded ? "📂" : "📁")</span>
<span>components</span>
</div>
<BitBhTreeGroup TValue="string" class="tree-group">
<BitBhTreeItem TValue="string" Value="@("src/components/Button.razor")" Label="Button.razor" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">📄</span>
<span>Button.razor</span>
</div>
</BitBhTreeItem>
<BitBhTreeItem TValue="string" Value="@("src/components/Tree.razor")" Label="Tree.razor" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">📄</span>
<span>Tree.razor</span>
</div>
</BitBhTreeItem>
</BitBhTreeGroup>
</Template>
</BitBhTreeItem>
<BitBhTreeItem TValue="string" Value="@("src/services/")" Label="services" class="tree-item">
<Template Context="servicesDir">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">@(servicesDir.IsExpanded ? "📂" : "📁")</span>
<span>services</span>
</div>
<BitBhTreeGroup TValue="string" class="tree-group">
<BitBhTreeItem TValue="string" Value="@("src/services/ApiClient.cs")" Label="ApiClient.cs" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">📄</span>
<span>ApiClient.cs</span>
</div>
</BitBhTreeItem>
<BitBhTreeItem TValue="string" Value="@("src/services/AuthService.cs")" Label="AuthService.cs" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">📄</span>
<span>AuthService.cs</span>
</div>
</BitBhTreeItem>
</BitBhTreeGroup>
</Template>
</BitBhTreeItem>
<BitBhTreeItem TValue="string" Value="@("src/Program.cs")" Label="Program.cs" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">📄</span>
<span>Program.cs</span>
</div>
</BitBhTreeItem>
</BitBhTreeGroup>
</Template>
</BitBhTreeItem>
<BitBhTreeItem TValue="string" Value="@("wwwroot/")" Label="wwwroot" class="tree-item">
<Template Context="wwwDir">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">@(wwwDir.IsExpanded ? "📂" : "📁")</span>
<span>wwwroot</span>
</div>
<BitBhTreeGroup TValue="string" class="tree-group">
<BitBhTreeItem TValue="string" Value="@("wwwroot/app.css")" Label="app.css" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">🎨</span>
<span>app.css</span>
</div>
</BitBhTreeItem>
<BitBhTreeItem TValue="string" Value="@("wwwroot/favicon.png")" Label="favicon.png" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">🖼️</span>
<span>favicon.png</span>
</div>
</BitBhTreeItem>
</BitBhTreeGroup>
</Template>
</BitBhTreeItem>
<BitBhTreeItem TValue="string" Value="@("secrets.json")" Label="secrets.json" Disabled="true" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">🔒</span>
<span>secrets.json</span>
<span class="tree-meta">locked</span>
</div>
</BitBhTreeItem>
<BitBhTreeItem TValue="string" Value="@("README.md")" Label="README.md" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">📄</span>
<span>README.md</span>
</div>
</BitBhTreeItem>
</BitBhTree>
<p class="tree-output">
Selected: <strong>@(selectedFile ?? "(none)")</strong> ·
Last activated: <strong>@(openedFile ?? "—")</strong>
</p>
@code {
private string? selectedFile = "src/components/Tree.razor";
private string? openedFile;
}
@* Label feeds typeahead: the item's text content would also include the
chevron and every descendant's name. *@/* All styles are consumer-authored — the library ships zero CSS */
.tree-demo { display: flex; flex-direction: column; align-items: flex-start; gap: 0.75rem; width: 100%; }
.tree-output { font-size: 0.875rem; color: #71717a; }
.tree-output strong { color: #18181b; font-weight: 600; }
/* BitBhTree renders <ul role="tree">, BitBhTreeGroup <ul role="group">. */
.tree,
.tree-group {
margin: 0;
padding: 0;
list-style: none;
}
.tree {
width: 100%;
max-width: 22rem;
padding: 0.375rem;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
font-size: 0.875rem;
color: #18181b;
}
/* <li role="treeitem"> wraps its own row AND its child group, so state styles
target the item's direct .tree-row child — a selected folder must not tint
the rows of its children. */
.tree-item { outline: none; }
.tree-row {
display: flex;
align-items: center;
gap: 0.375rem;
min-height: 1.875rem;
padding: 0.2rem 0.5rem;
/* --bit-bh-tree-level (1, 2, 3, …) is emitted on every BitBhTreeItem. */
padding-inline-start: calc((var(--bit-bh-tree-level, 1) - 1) * 1.25rem + 0.375rem);
border-radius: 0.375rem;
cursor: pointer;
user-select: none;
}
.tree-row:hover { background: #f4f4f5; }
/* Roving focus: the focused <li> draws its ring on its own row. */
.tree-item:focus-visible > .tree-row { outline: 2px solid #2563eb; outline-offset: -2px; }
/* aria-selected is emitted in single / multiple selection mode. */
.tree-item[aria-selected="true"] > .tree-row { background: #eff6ff; color: #1d4ed8; font-weight: 500; }
.tree-item[data-disabled] > .tree-row { background: transparent; opacity: 0.45; cursor: not-allowed; }
/* BitBhTreeItemToggle: data-state="open|closed" on branches, data-leaf on leaves. */
.tree-toggle {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
color: #a1a1aa;
font-size: 0.75rem;
transition: transform 150ms ease;
}
.tree-toggle:hover { background: #e4e4e7; color: #18181b; }
.tree-toggle[data-state="open"] { transform: rotate(90deg); }
.tree-toggle[data-leaf] { visibility: hidden; }
.tree-icon { flex: none; width: 1.25rem; text-align: center; }
.tree-meta { margin-inline-start: auto; font-size: 0.75rem; color: #a1a1aa; }Data-driven (recursive)
Render the tree from a model with a recursive RenderFragment<Node> helper. Each
BitBhTreeItem works out its depth from where it is rendered, so the markup is the same at
every level. Supplying @bind-ExpandedValues makes expansion controlled, so plain
buttons can expand or collapse everything.
- Getting started2
- Installation
- Quick start
- Components2
- Overlays3
- Dialog
- Popover
- Tooltip
- Collections3
- Listbox
- Tree View
- Grid
- Styling2
- Plain CSS
- Tailwind
Selected: tree · Expanded: 2
<div class="tree-actions">
<button type="button" class="tree-button" @onclick="ExpandAllDocs">Expand all</button>
<button type="button" class="tree-button" @onclick="CollapseAllDocs">Collapse all</button>
</div>
<BitBhTree TValue="string" aria-label="Documentation" class="tree"
@bind-SelectedValue="selectedDoc"
@bind-ExpandedValues="expandedDocs">
@foreach (var node in docs)
{
@DocItem(node)
}
</BitBhTree>
<p class="tree-output">
Selected: <strong>@(selectedDoc ?? "(none)")</strong> ·
Expanded: <strong>@expandedDocs.Count</strong>
</p>
@code {
private sealed record Node(string Id, string Name, Node[] Children);
private readonly Node[] docs =
[
new("start", "Getting started",
[
new("installation", "Installation", []),
new("quick-start", "Quick start", []),
]),
new("components", "Components",
[
new("overlays", "Overlays",
[
new("dialog", "Dialog", []),
new("popover", "Popover", []),
new("tooltip", "Tooltip", []),
]),
new("collections", "Collections",
[
new("listbox", "Listbox", []),
new("tree", "Tree View", []),
new("grid", "Grid", []),
]),
]),
new("styling", "Styling",
[
new("plain-css", "Plain CSS", []),
new("tailwind", "Tailwind", []),
]),
];
private string? selectedDoc = "tree";
private IReadOnlyCollection<string> expandedDocs = ["components", "collections"];
private void ExpandAllDocs() => expandedDocs = Branches(docs).ToArray();
private void CollapseAllDocs() => expandedDocs = [];
private static IEnumerable<string> Branches(IEnumerable<Node> nodes) =>
nodes.Where(n => n.Children.Length > 0).SelectMany(n => Branches(n.Children).Prepend(n.Id));
// One template renders every level — it simply calls itself for the children.
private RenderFragment<Node> DocItem => node =>
@<BitBhTreeItem TValue="string" Value="@node.Id" Label="@node.Name" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span>@node.Name</span>
@if (node.Children.Length > 0)
{
<span class="tree-count">@node.Children.Length</span>
}
</div>
@if (node.Children.Length > 0)
{
<BitBhTreeGroup TValue="string" class="tree-group">
@foreach (var child in node.Children)
{
@DocItem(child)
}
</BitBhTreeGroup>
}
</BitBhTreeItem>;
}/* All styles are consumer-authored — the library ships zero CSS */
.tree-demo { display: flex; flex-direction: column; align-items: flex-start; gap: 0.75rem; width: 100%; }
.tree-output { font-size: 0.875rem; color: #71717a; }
.tree-output strong { color: #18181b; font-weight: 600; }
/* BitBhTree renders <ul role="tree">, BitBhTreeGroup <ul role="group">. */
.tree,
.tree-group {
margin: 0;
padding: 0;
list-style: none;
}
.tree {
width: 100%;
max-width: 22rem;
padding: 0.375rem;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
font-size: 0.875rem;
color: #18181b;
}
/* <li role="treeitem"> wraps its own row AND its child group, so state styles
target the item's direct .tree-row child — a selected folder must not tint
the rows of its children. */
.tree-item { outline: none; }
.tree-row {
display: flex;
align-items: center;
gap: 0.375rem;
min-height: 1.875rem;
padding: 0.2rem 0.5rem;
/* --bit-bh-tree-level (1, 2, 3, …) is emitted on every BitBhTreeItem. */
padding-inline-start: calc((var(--bit-bh-tree-level, 1) - 1) * 1.25rem + 0.375rem);
border-radius: 0.375rem;
cursor: pointer;
user-select: none;
}
.tree-row:hover { background: #f4f4f5; }
/* Roving focus: the focused <li> draws its ring on its own row. */
.tree-item:focus-visible > .tree-row { outline: 2px solid #2563eb; outline-offset: -2px; }
/* aria-selected is emitted in single / multiple selection mode. */
.tree-item[aria-selected="true"] > .tree-row { background: #eff6ff; color: #1d4ed8; font-weight: 500; }
.tree-item[data-disabled] > .tree-row { background: transparent; opacity: 0.45; cursor: not-allowed; }
/* BitBhTreeItemToggle: data-state="open|closed" on branches, data-leaf on leaves. */
.tree-toggle {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
color: #a1a1aa;
font-size: 0.75rem;
transition: transform 150ms ease;
}
.tree-toggle:hover { background: #e4e4e7; color: #18181b; }
.tree-toggle[data-state="open"] { transform: rotate(90deg); }
.tree-toggle[data-leaf] { visibility: hidden; }
.tree-icon { flex: none; width: 1.25rem; text-align: center; }
.tree-count {
margin-inline-start: auto;
min-width: 1.25rem;
padding: 0 0.375rem;
border-radius: 999px;
background: #f4f4f5;
color: #71717a;
font-size: 0.7rem;
text-align: center;
}
.tree-actions { display: flex; gap: 0.5rem; }
.tree-button {
padding: 0.35rem 0.75rem;
border: 1px solid #e4e4e7;
border-radius: 0.375rem;
background: #fff;
font: inherit;
font-size: 0.8rem;
font-weight: 500;
color: #18181b;
cursor: pointer;
}
.tree-button:hover { background: #f4f4f5; }
.tree-button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }Multiple selection
SelectionMode="BitBhTreeSelectionMode.Multiple" sets aria-multiselectable and
binds with @bind-SelectedValues. Click selects one item, Ctrl / ⌘ + click toggles,
Shift + click selects a range. Keyboard: Space toggles the focused item, Shift + Up / Down extends
the selection, Ctrl + A selects everything.
- Inbox 3
- Work
- Personal
- Receipts
- Projects 3
- Apollo
- Gemini
- Mercury
- Archive 2
- 2024
- 2025
0 selected: —
<BitBhTree TValue="string" aria-label="Mailboxes" class="tree"
SelectionMode="BitBhTreeSelectionMode.Multiple"
@bind-SelectedValues="selectedBoxes"
DefaultExpandedValues="@(new[] { "Inbox", "Projects" })">
@foreach (var (icon, name, children) in mailboxes)
{
<BitBhTreeItem TValue="string" Value="@name" Label="@name" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">@icon</span>
<span>@name</span>
<span class="tree-count">@children.Length</span>
</div>
<BitBhTreeGroup TValue="string" class="tree-group">
@foreach (var child in children)
{
<BitBhTreeItem TValue="string" Value="@($"{name}/{child}")" Label="@child" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">📁</span>
<span>@child</span>
</div>
</BitBhTreeItem>
}
</BitBhTreeGroup>
</BitBhTreeItem>
}
</BitBhTree>
<p class="tree-output">
@selectedBoxes.Count selected: <strong>@(selectedBoxes.Count == 0 ? "—" : string.Join(", ", selectedBoxes))</strong>
</p>
@code {
private readonly (string Icon, string Name, string[] Children)[] mailboxes =
[
("📥", "Inbox", ["Work", "Personal", "Receipts"]),
("🗂️", "Projects", ["Apollo", "Gemini", "Mercury"]),
("🗄️", "Archive", ["2024", "2025"]),
];
private IReadOnlyCollection<string> selectedBoxes = [];
}/* All styles are consumer-authored — the library ships zero CSS */
.tree-demo { display: flex; flex-direction: column; align-items: flex-start; gap: 0.75rem; width: 100%; }
.tree-output { font-size: 0.875rem; color: #71717a; }
.tree-output strong { color: #18181b; font-weight: 600; }
/* BitBhTree renders <ul role="tree">, BitBhTreeGroup <ul role="group">. */
.tree,
.tree-group {
margin: 0;
padding: 0;
list-style: none;
}
.tree {
width: 100%;
max-width: 22rem;
padding: 0.375rem;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
font-size: 0.875rem;
color: #18181b;
}
/* <li role="treeitem"> wraps its own row AND its child group, so state styles
target the item's direct .tree-row child — a selected folder must not tint
the rows of its children. */
.tree-item { outline: none; }
.tree-row {
display: flex;
align-items: center;
gap: 0.375rem;
min-height: 1.875rem;
padding: 0.2rem 0.5rem;
/* --bit-bh-tree-level (1, 2, 3, …) is emitted on every BitBhTreeItem. */
padding-inline-start: calc((var(--bit-bh-tree-level, 1) - 1) * 1.25rem + 0.375rem);
border-radius: 0.375rem;
cursor: pointer;
user-select: none;
}
.tree-row:hover { background: #f4f4f5; }
/* Roving focus: the focused <li> draws its ring on its own row. */
.tree-item:focus-visible > .tree-row { outline: 2px solid #2563eb; outline-offset: -2px; }
/* aria-selected is emitted in single / multiple selection mode. */
.tree-item[aria-selected="true"] > .tree-row { background: #eff6ff; color: #1d4ed8; font-weight: 500; }
.tree-item[data-disabled] > .tree-row { background: transparent; opacity: 0.45; cursor: not-allowed; }
/* BitBhTreeItemToggle: data-state="open|closed" on branches, data-leaf on leaves. */
.tree-toggle {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
color: #a1a1aa;
font-size: 0.75rem;
transition: transform 150ms ease;
}
.tree-toggle:hover { background: #e4e4e7; color: #18181b; }
.tree-toggle[data-state="open"] { transform: rotate(90deg); }
.tree-toggle[data-leaf] { visibility: hidden; }
.tree-icon { flex: none; width: 1.25rem; text-align: center; }
.tree-count {
margin-inline-start: auto;
min-width: 1.25rem;
padding: 0 0.375rem;
border-radius: 999px;
background: #f4f4f5;
color: #71717a;
font-size: 0.7rem;
text-align: center;
}Hierarchical checkboxes
Checkboxes="true" turns the tree into a hierarchical picker: checking a parent checks all
of its descendants, and a parent whose leaves are only partly checked reports
aria-checked="mixed". Every item also emits data-checked="true|false|mixed",
so the box below is drawn with CSS alone. Click a row or press Space to toggle it.
- Desktop app
- Core runtime
- Spell checker
- Themes
- Light
- Dark
- High contrast
- Command-line tools
- CLI
- Shell completions
- Offline documentation
Installing: Core runtime, Light, Dark
<BitBhTree TValue="string" aria-label="Features to install" class="tree"
Checkboxes="true"
@bind-SelectedValues="checkedFeatures"
DefaultExpandedValues="@(new[] { "app", "app-themes" })">
@foreach (var node in features)
{
@FeatureItem(node)
}
</BitBhTree>
<p class="tree-output">
Installing: <strong>@(InstalledFeatures.Any() ? string.Join(", ", InstalledFeatures) : "nothing")</strong>
</p>
@code {
private sealed record Node(string Id, string Name, Node[] Children);
private readonly Node[] features =
[
new("app", "Desktop app",
[
new("app-core", "Core runtime", []),
new("app-spellcheck", "Spell checker", []),
new("app-themes", "Themes",
[
new("theme-light", "Light", []),
new("theme-dark", "Dark", []),
new("theme-contrast", "High contrast", []),
]),
]),
new("cli", "Command-line tools",
[
new("cli-core", "CLI", []),
new("cli-completions", "Shell completions", []),
]),
new("offline-docs", "Offline documentation", []),
];
// Parents are added / removed automatically as their leaves change.
private IReadOnlyCollection<string> checkedFeatures = ["app-core", "theme-light", "theme-dark"];
private IEnumerable<string> InstalledFeatures =>
Leaves(features).Where(n => checkedFeatures.Contains(n.Id)).Select(n => n.Name);
private static IEnumerable<Node> Leaves(IEnumerable<Node> nodes) =>
nodes.SelectMany(n => n.Children.Length == 0 ? new[] { n } : Leaves(n.Children));
private RenderFragment<Node> FeatureItem => node =>
@<BitBhTreeItem TValue="string" Value="@node.Id" Label="@node.Name" class="tree-item">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-check" aria-hidden="true"></span>
<span>@node.Name</span>
</div>
@if (node.Children.Length > 0)
{
<BitBhTreeGroup TValue="string" class="tree-group">
@foreach (var child in node.Children)
{
@FeatureItem(child)
}
</BitBhTreeGroup>
}
</BitBhTreeItem>;
}/* All styles are consumer-authored — the library ships zero CSS */
.tree-demo { display: flex; flex-direction: column; align-items: flex-start; gap: 0.75rem; width: 100%; }
.tree-output { font-size: 0.875rem; color: #71717a; }
.tree-output strong { color: #18181b; font-weight: 600; }
/* BitBhTree renders <ul role="tree">, BitBhTreeGroup <ul role="group">. */
.tree,
.tree-group {
margin: 0;
padding: 0;
list-style: none;
}
.tree {
width: 100%;
max-width: 22rem;
padding: 0.375rem;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
font-size: 0.875rem;
color: #18181b;
}
/* <li role="treeitem"> wraps its own row AND its child group, so state styles
target the item's direct .tree-row child — a selected folder must not tint
the rows of its children. */
.tree-item { outline: none; }
.tree-row {
display: flex;
align-items: center;
gap: 0.375rem;
min-height: 1.875rem;
padding: 0.2rem 0.5rem;
/* --bit-bh-tree-level (1, 2, 3, …) is emitted on every BitBhTreeItem. */
padding-inline-start: calc((var(--bit-bh-tree-level, 1) - 1) * 1.25rem + 0.375rem);
border-radius: 0.375rem;
cursor: pointer;
user-select: none;
}
.tree-row:hover { background: #f4f4f5; }
/* Roving focus: the focused <li> draws its ring on its own row. */
.tree-item:focus-visible > .tree-row { outline: 2px solid #2563eb; outline-offset: -2px; }
/* aria-selected is emitted in single / multiple selection mode. */
.tree-item[aria-selected="true"] > .tree-row { background: #eff6ff; color: #1d4ed8; font-weight: 500; }
.tree-item[data-disabled] > .tree-row { background: transparent; opacity: 0.45; cursor: not-allowed; }
/* BitBhTreeItemToggle: data-state="open|closed" on branches, data-leaf on leaves. */
.tree-toggle {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
color: #a1a1aa;
font-size: 0.75rem;
transition: transform 150ms ease;
}
.tree-toggle:hover { background: #e4e4e7; color: #18181b; }
.tree-toggle[data-state="open"] { transform: rotate(90deg); }
.tree-toggle[data-leaf] { visibility: hidden; }
.tree-icon { flex: none; width: 1.25rem; text-align: center; }
/* With Checkboxes="true" every item carries aria-checked and
data-checked="true|false|mixed"; a parent is mixed when only some of its
leaves are checked. The box itself is decoration (aria-hidden). */
.tree-check {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border: 1.5px solid #a1a1aa;
border-radius: 0.25rem;
background: #fff;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
}
.tree-item[data-checked="true"] > .tree-row .tree-check,
.tree-item[data-checked="mixed"] > .tree-row .tree-check { background: #2563eb; border-color: #2563eb; }
.tree-item[data-checked="true"] > .tree-row .tree-check::after { content: "✓"; }
.tree-item[data-checked="mixed"] > .tree-row .tree-check::after { content: "–"; }Lazy loading
Mark folders whose children are not loaded yet with HasChildren="true" and supply
LoadChildren. It runs the first time a folder expands; meanwhile the item reports
aria-busy="true" (and IsLoading in its Template) so you can show
a spinner. The folder opens once the children are rendered.
- media-bucket
- logs-bucket
- backups-bucket
<BitBhTree TValue="string" aria-label="Cloud storage" class="tree"
LoadChildren="LoadChildrenAsync">
@foreach (var bucket in buckets)
{
@RemoteItem(bucket)
}
</BitBhTree>
@code {
private sealed record RemoteEntry(string Path, string Name, bool IsFolder);
private readonly RemoteEntry[] buckets =
[
new("media", "media-bucket", true),
new("logs", "logs-bucket", true),
new("backups", "backups-bucket", true),
];
private readonly Dictionary<string, RemoteEntry[]> loadedChildren = new();
// Called once per folder, the first time it expands.
private async Task LoadChildrenAsync(string path)
{
await Task.Delay(800); // simulate a network round-trip
if (path.Contains('/'))
{
loadedChildren[path] =
[
new($"{path}/january.csv", "january.csv", false),
new($"{path}/february.csv", "february.csv", false),
];
}
else
{
loadedChildren[path] =
[
new($"{path}/2025", "2025", true),
new($"{path}/2026", "2026", true),
new($"{path}/manifest.json", "manifest.json", false),
];
}
StateHasChanged();
}
private RenderFragment<RemoteEntry> RemoteItem => entry =>
@<BitBhTreeItem TValue="string" Value="@entry.Path" Label="@entry.Name" HasChildren="entry.IsFolder" class="tree-item">
<Template Context="state">
<div class="tree-row">
<BitBhTreeItemToggle TValue="string" class="tree-toggle">▸</BitBhTreeItemToggle>
<span class="tree-icon">@(entry.IsFolder ? (state.IsExpanded ? "📂" : "📁") : "📄")</span>
<span>@entry.Name</span>
@if (state.IsLoading)
{
<span class="tree-meta">Loading…</span>
<span class="tree-spinner" aria-hidden="true"></span>
}
</div>
@if (loadedChildren.TryGetValue(entry.Path, out var children))
{
<BitBhTreeGroup TValue="string" class="tree-group">
@foreach (var child in children)
{
@RemoteItem(child)
}
</BitBhTreeGroup>
}
</Template>
</BitBhTreeItem>;
}/* All styles are consumer-authored — the library ships zero CSS */
.tree-demo { display: flex; flex-direction: column; align-items: flex-start; gap: 0.75rem; width: 100%; }
.tree-output { font-size: 0.875rem; color: #71717a; }
.tree-output strong { color: #18181b; font-weight: 600; }
/* BitBhTree renders <ul role="tree">, BitBhTreeGroup <ul role="group">. */
.tree,
.tree-group {
margin: 0;
padding: 0;
list-style: none;
}
.tree {
width: 100%;
max-width: 22rem;
padding: 0.375rem;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
font-size: 0.875rem;
color: #18181b;
}
/* <li role="treeitem"> wraps its own row AND its child group, so state styles
target the item's direct .tree-row child — a selected folder must not tint
the rows of its children. */
.tree-item { outline: none; }
.tree-row {
display: flex;
align-items: center;
gap: 0.375rem;
min-height: 1.875rem;
padding: 0.2rem 0.5rem;
/* --bit-bh-tree-level (1, 2, 3, …) is emitted on every BitBhTreeItem. */
padding-inline-start: calc((var(--bit-bh-tree-level, 1) - 1) * 1.25rem + 0.375rem);
border-radius: 0.375rem;
cursor: pointer;
user-select: none;
}
.tree-row:hover { background: #f4f4f5; }
/* Roving focus: the focused <li> draws its ring on its own row. */
.tree-item:focus-visible > .tree-row { outline: 2px solid #2563eb; outline-offset: -2px; }
/* aria-selected is emitted in single / multiple selection mode. */
.tree-item[aria-selected="true"] > .tree-row { background: #eff6ff; color: #1d4ed8; font-weight: 500; }
.tree-item[data-disabled] > .tree-row { background: transparent; opacity: 0.45; cursor: not-allowed; }
/* BitBhTreeItemToggle: data-state="open|closed" on branches, data-leaf on leaves. */
.tree-toggle {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
color: #a1a1aa;
font-size: 0.75rem;
transition: transform 150ms ease;
}
.tree-toggle:hover { background: #e4e4e7; color: #18181b; }
.tree-toggle[data-state="open"] { transform: rotate(90deg); }
.tree-toggle[data-leaf] { visibility: hidden; }
.tree-icon { flex: none; width: 1.25rem; text-align: center; }
.tree-meta { margin-inline-start: auto; font-size: 0.75rem; color: #a1a1aa; }
/* aria-busy="true" is set while LoadChildren runs. */
.tree-item[aria-busy="true"] > .tree-row { color: #71717a; cursor: progress; }
.tree-spinner {
flex: none;
width: 0.875rem;
height: 0.875rem;
border: 2px solid #dbeafe;
border-top-color: #2563eb;
border-radius: 50%;
animation: tree-spin 0.7s linear infinite;
}
@keyframes tree-spin { to { transform: rotate(360deg); } }