TreeView<TItem> handled only Enter/Space on the chevron; the tree itself was unreachable by keyboard. Implements the WAI-ARIA tree pattern: - Roving tabindex on li[role=treeitem] — exactly one node is tabbable, so the whole tree is a single Tab stop. Target resolved per render: last-focused node while still visible -> SelectedKey -> first visible node. Browser focus follows via a per-node ElementReference + FocusAsync in OnAfterRenderAsync, guarded by the same JSException/JSDisconnectedException/InvalidOperationException triple the context-menu focus already used (a no-op under bUnit). - ArrowDown/ArrowUp move between VISIBLE nodes; ArrowRight expands a collapsed branch else moves to first child; ArrowLeft collapses an expanded branch else moves to parent; Home/End jump to first/last; Enter/Space activate through the SAME path a click takes (OnContentClick / OnCheckboxToggle) so selection semantics never diverge between input modes. - ARIA: aria-level, aria-posinset, aria-setsize added; aria-selected now renders true/false on selectable+checkbox trees and stays absent on non-selectable ones. - Event scoping: @onkeydown:stopPropagation on the li, chevron, checkbox and content slot, so nested nodes do not double-handle and consumer controls inside NodeContent keep their own key handling. Browser scroll-on-Space/Arrow is suppressed by a minimal NATIVE inline onkeydown on the root ul, targeted at treeitems only — Blazor's preventDefault directive is all-or-nothing per element, so on the li it would trap Tab and cancel Enter/Space on consumer buttons. No CSP is configured, so the inline handler runs. BuildVisibleNodes() mirrors RenderNode's visibility rules and must stay in step. Tests: 31 new bUnit tests in TreeViewKeyboardNavigationTests; the 47 existing TreeView tests are unchanged and still green. Docs: new keyboard/a11y section in docs/components/TreeView.md.
17 KiB
Tree View
TreeView<TItem> is a generic, reusable Blazor Server component that renders any tree-shaped data as an expandable/collapsible hierarchy with ARIA roles, optional guide lines, single or checkbox selection, and session-persistent expansion state.
Overview
The component lives at src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor alongside its scoped stylesheet TreeView.razor.css. It is data-agnostic: the caller supplies the data source, a children accessor, a key function, and a RenderFragment<TItem> for node content — the component owns only the structural chrome (indentation, toggle, guide lines, ARIA attributes, selection highlight, context menu positioning).
Active uses within the Central UI:
- Data Connections page (
DataConnections.razor) — two-level Site → Connection tree with a kebab action menu per node and search-based dimming. - Topology page (
Topology.razor) — three-level Site → Area → Instance tree with inline rename, context menus, andStorageKey = "topology-tree"expansion persistence. TemplateFolderTree(TemplateFolderTree.razor) — a domain-specific wrapper that projectsTemplateFolder/Templateentities intoTemplateTreeNodeitems and delegates toTreeView<TemplateTreeNode>. Consumed by the Templates browser page (Single mode, click-to-navigate) and the Transport Export wizard (Checkbox mode, bulk template selection).
Key Concepts
Generic node typing
@typeparam TItem means the component imposes no node base class. The consumer brings its own model — a record, a class, an interface — and wires four [EditorRequired] delegates that teach the component how to navigate it. The TemplateTreeNode class in Components/Shared/TemplateTreeNode.cs is the shared adapter type for template/folder hierarchies; the Data Connections and Topology pages use local record types.
Expansion state
Expanded-node keys are stored in _expandedKeys (HashSet<string>). Keys are stringified via KeyStr(object) => key.ToString()! for consistency with sessionStorage, which stores them as a JSON array when StorageKey is set. On first render the component reads sessionStorage via treeviewStorage.load; on every toggle it writes back via treeviewStorage.save. InitiallyExpanded applies only when no persisted state exists (or StorageKey is null). The two sources are always unioned — a RevealNode call before the async storage read completes is not clobbered.
Selection modes
TreeViewSelectionMode (defined in TreeViewSelectionMode.cs) controls how nodes are selected:
public enum TreeViewSelectionMode
{
Single, // Default. Clicking node content fires SelectedKeyChanged with the node key.
Checkbox, // Renders a tri-state checkbox per node. Folder check state is aggregated
// from descendant leaves; only leaf keys enter SelectedKeys.
}
In Single mode the component uses SelectedKey / SelectedKeyChanged (two-way binding on a single object?). In Checkbox mode it uses SelectedKeys / SelectedKeysChanged (HashSet<object>). Checking a folder selects or deselects all of its descendant leaf keys. The indeterminate checkbox property is set via JS interop (treeviewStorage.setIndeterminate) after every render because Blazor does not bind input.indeterminate natively.
Context menu
When ContextMenu is non-null, right-clicking any row suppresses the browser default and positions a Bootstrap dropdown-menu show div at the cursor coordinates using position: fixed. An invisible overlay behind the menu dismisses it on click-outside; Escape also dismisses it. The menu receives the TItem of the right-clicked node, so the consumer's fragment can branch on node type.
Keyboard navigation & accessibility (WAI-ARIA tree pattern)
Delivered 2026-08-01 (M10 residual R7). The component implements the full WAI-ARIA tree pattern.
Roving tabindex. Exactly one li[role="treeitem"] carries tabindex="0" at a time; every other node is tabindex="-1", so the whole tree is a single Tab stop. The target is resolved once per render by ResolveTabbableKey(): the node the user last landed on (_focusedKey) while it is still visible → else the currently SelectedKey node → else the first visible node. A keyboard move sets _focusNeedsApply, and OnAfterRenderAsync pulls browser focus onto the new node via a per-node ElementReference, guarded by the same JSException / JSDisconnectedException / InvalidOperationException triple the context-menu focus uses (under bUnit there is no real focus, so it is a safe no-op).
| Key | Behaviour |
|---|---|
ArrowDown / ArrowUp |
Move to the next / previous visible node (a collapsed branch's children are skipped). No-op at the ends. |
ArrowRight |
Collapsed branch → expand (focus stays); expanded branch → move to first child; leaf → no-op. |
ArrowLeft |
Expanded branch → collapse (focus stays); otherwise → move to parent (via BuildParentLookup()). No-op at a root leaf. |
Home / End |
Move to the first / last visible node. |
Enter / Space |
Activate the node — routed to the same path a mouse click takes (OnContentClick in Single mode, OnCheckboxToggle in Checkbox mode), so selection semantics never diverge between input modes. |
The visible order is produced by BuildVisibleNodes(), which mirrors RenderNode's visibility rules exactly — the two must stay in step, or arrow navigation will skip or invent rows.
ARIA attributes. ul[role=tree] root, ul[role=group] for children, li[role=treeitem] nodes carrying aria-level (1-based), aria-posinset, aria-setsize, aria-expanded (branches only), and aria-selected (selectable / checkbox trees only — absent on a non-selectable tree).
Event scoping. @onkeydown:stopPropagation sits on the <li> (so a nested node's keypress is not also handled by its ancestors) and on the chevron, the checkbox, and the .tv-content slot — so a consumer's own buttons and inputs inside NodeContent keep their key handling. Browser scroll-on-Space/Arrow is suppressed by a small native inline onkeydown on the root <ul> that calls preventDefault() only for the navigation keys and only when the event target is the treeitem itself; Blazor's preventDefault directive is all-or-nothing per element, so putting it on the <li> would both trap Tab inside the tree and cancel Enter/Space activation of consumer buttons. (The app sets no Content-Security-Policy, so the inline handler executes.)
Covered by tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs (31 bUnit tests).
Architecture
The component is a single @typeparam .razor file with a private void RenderNode(TItem item, int depth, int posInSet, int setSize) local function that recurses the tree at render time — no intermediate view model is built inside the component. Every <li> carries @key="key" so Blazor can diff the list efficiently.
IJSRuntime is injected for two purposes: reading/writing sessionStorage for expansion persistence, and setting input.indeterminate for tri-state checkboxes. Both call sites guard JSDisconnectedException so a disconnected circuit never throws out of the lifecycle methods.
The public surface the caller can invoke via @ref:
public bool IsExpanded(object key); // Whether the given key is currently expanded.
public void ExpandAll(); // Expand every branch node; persists if StorageKey set.
public void CollapseAll(); // Collapse every node; clears persisted state.
public async Task RevealNode(object key, bool select = false);
// Expands all ancestors of the given key. Optionally selects the node.
// No-op if the key does not exist in the current tree.
Usage
The Data Connections page binds a two-level Site → Connection tree with StorageKey, single selection, and a context menu for Edit and Delete actions on connection nodes:
<TreeView @ref="_tree" TItem="DcTreeNode" Items="_treeRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => (object)n.Key"
StorageKey="data-connections-tree"
Selectable="true"
SelectedKey="_selectedKey"
SelectedKeyChanged="OnTreeNodeSelected">
<NodeContent Context="node">
@if (node.Kind == DcNodeKind.Site)
{
<span class="tv-label fw-semibold">@node.Label</span>
<span class="badge bg-secondary ms-1">@node.Children.Count</span>
}
else
{
<span class="tv-label">@node.Label</span>
<span class="badge bg-info ms-2">@node.Connection!.Protocol</span>
}
</NodeContent>
<ContextMenu Context="node">
@if (node.Kind == DcNodeKind.Site)
{
<button class="dropdown-item"
@onclick="() => AddConnectionForSite(node.SiteId!.Value)">
Add Connection here
</button>
}
else
{
<button class="dropdown-item"
@onclick='() => NavigationManager.NavigateTo($"/design/connections/{node.Connection!.Id}/edit")'>
Edit
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger"
@onclick="() => DeleteConnection(node.Connection!)">
Delete
</button>
}
</ContextMenu>
<EmptyContent>
<span class="text-muted fst-italic">No sites configured. Add sites under Admin → Sites.</span>
</EmptyContent>
</TreeView>
@code {
record DcTreeNode(string Key, string Label, DcNodeKind Kind, List<DcTreeNode> Children,
int? SiteId = null, DataConnection? Connection = null);
enum DcNodeKind { Site, DataConnection }
private TreeView<DcTreeNode>? _tree;
private object? _selectedKey;
private void OnTreeNodeSelected(object? key) => _selectedKey = key;
}
The TemplateFolderTree wrapper demonstrates Checkbox mode, where SelectedKeys / SelectedKeysChanged drive bulk template selection in the Transport Export wizard:
<TreeView @ref="_tree" TItem="TemplateTreeNode"
Items="_visibleRoots"
ChildrenSelector="n => n.Children"
HasChildrenSelector="n => n.Children.Count > 0"
KeySelector="n => (object)n.Key"
Selectable="@(SelectionMode == TreeViewSelectionMode.Single)"
SelectionMode="SelectionMode"
SelectedKeys="SelectedKeys"
SelectedKeysChanged="SelectedKeysChanged"
InitiallyExpanded="@(_initiallyExpanded)"
StorageKey="@StorageKey">
<NodeContent Context="node">
<span class="tv-glyph"><i class="bi @(NodeGlyph(node))"></i></span>
<span class="tv-label @(node.Children.Count > 0 ? "fw-semibold" : "")"
title="@node.Name">@node.Name</span>
</NodeContent>
</TreeView>
Configuration
All [Parameter] properties on TreeView<TItem>. Parameters marked required carry [EditorRequired] and must be supplied; omitting them produces a build warning.
| Parameter | Type | Default | Description |
|---|---|---|---|
Items |
IReadOnlyList<TItem> |
— | Required. Root-level nodes to render. |
ChildrenSelector |
Func<TItem, IReadOnlyList<TItem>> |
— | Required. Returns the ordered children of a node. |
HasChildrenSelector |
Func<TItem, bool> |
— | Required. Returns true for branch nodes. Determines whether the expand toggle is rendered. |
KeySelector |
Func<TItem, object> |
— | Required. Unique stable key per node. Used for expansion tracking, selection, and @key diffing. |
NodeContent |
RenderFragment<TItem> |
— | Required. Render fragment for node label content. Receives the TItem; responsible for all domain-specific markup (glyphs, labels, badges). |
EmptyContent |
RenderFragment? |
null |
Shown when Items is empty or null. |
ContextMenu |
RenderFragment<TItem>? |
null |
Right-click menu content. Receives the right-clicked node. If null, right-click is not intercepted and the browser default is preserved. If non-null, @oncontextmenu:preventDefault is always active — the browser default is suppressed for every node regardless of whether the fragment renders any items for that node type. |
IndentPx |
int |
24 |
Pixels of left padding added per depth level via inline style. |
ShowGuideLines |
bool |
true |
Adds tv-guides CSS class to the root <ul>, enabling the depth guide lines drawn by a linear-gradient pseudo-element in TreeView.razor.css. |
InitiallyExpanded |
Func<TItem, bool>? |
null |
Predicate used to expand matching nodes on first load. When StorageKey is null it is applied immediately (synchronously in OnParametersSet). When StorageKey is set, the predicate is applied only after the async storage read completes and returns empty — persisted state takes precedence and InitiallyExpanded is a fallback for first-ever loads. |
StorageKey |
string? |
null |
Browser sessionStorage key for expansion persistence (treeview:{StorageKey}). When null, expansion is in-memory only. |
Selectable |
bool |
false |
Enables click-to-select on node content. Clicking the expand toggle never changes selection. |
SelectedKey |
object? |
null |
Currently selected node key for Single mode. Supports two-way binding (@bind-SelectedKey). |
SelectedKeyChanged |
EventCallback<object?> |
— | Fires when selection changes in Single mode. Also fires on RevealNode(..., select: true). Fires with null when the selected key disappears from the tree. |
SelectedCssClass |
string |
"bg-primary bg-opacity-10" |
CSS class(es) applied to the selected node's row div in addition to tv-selected. |
SelectionMode |
TreeViewSelectionMode |
Single |
Switches between single-key selection and tri-state checkbox selection. |
SelectedKeys |
HashSet<object>? |
null |
Set of currently selected leaf keys for Checkbox mode. |
SelectedKeysChanged |
EventCallback<HashSet<object>> |
— | Fires with the updated set when any checkbox is toggled in Checkbox mode. Always fires with a fresh HashSet reference. |
CSS utility classes in NodeContent
The scoped stylesheet defines layout slots that NodeContent fragments should use for consistent alignment:
| Class | Purpose |
|---|---|
tv-glyph |
20 px flex slot for a Bootstrap Icon (<i class="bi bi-…">). |
tv-label |
flex: 1 1 auto; min-width: 0 — primary text with ellipsis overflow. |
tv-meta |
margin-left: auto — right-aligned badges or trailing controls. |
tv-kebab |
Opt-in hidden-by-default "more actions" slot; revealed on row hover. |
Dependencies & Interactions
- Bootstrap 5 — all state visuals use Bootstrap utility classes and CSS variables (
--bs-tertiary-bg,--bs-border-color,--bs-primary-rgb). No third-party Blazor component frameworks. - Bootstrap Icons — static files served from
wwwroot/lib/bootstrap-icons/; referenced once inMainLayout.razor.NodeContentfragments use<i class="bi bi-…">inside thetv-glyphslot. IJSRuntime— injected fortreeviewStorage.load/treeviewStorage.save(expansion persistence) andtreeviewStorage.setIndeterminate(checkbox tri-state). The JS helpers live in the CentralUI's shared JS bundle.TemplateFolderTree(Components/Shared/TemplateFolderTree.razor) — a domain-specific wrapper aroundTreeView<TemplateTreeNode>that handles folder/template tree construction, text filtering, andExtraTemplateChildreninjection. Consumers that need the template hierarchy useTemplateFolderTree; they do not wireTreeView<TemplateTreeNode>directly.TemplateTreeNode/TemplateTreeNodeKind(Components/Shared/TemplateTreeNode.cs) — the shared node model used byTemplateFolderTreeand its callers. Folder keys are prefixedf:, template keyst:, composition keysc:.- Data Connections page (
Components/Pages/Design/DataConnections.razor) — bindsTreeView<DcTreeNode>directly with a local two-level record type. - Topology page (
Components/Pages/Deployment/Topology.razor) — bindsTreeViewfor the Site → Area → Instance hierarchy; callsExpandAllandCollapseAllvia@ref. - Central UI component — see ./CentralUI.md for the broader Blazor Server application context.