feat(centralui): TreeView full WAI-ARIA keyboard navigation (M10 residual R7)

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.
This commit is contained in:
Joseph Doherty
2026-08-01 11:26:23 -04:00
parent 8aa6bf2270
commit fdfd5e1b27
4 changed files with 843 additions and 17 deletions
+23 -1
View File
@@ -41,9 +41,31 @@ In `Single` mode the component uses `SelectedKey` / `SelectedKeyChanged` (two-wa
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. 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](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/).
**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 ## Architecture
The component is a single `@typeparam` `.razor` file with a private `void RenderNode(TItem item, int depth)` 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. 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. `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.
@@ -2,6 +2,13 @@
@typeparam TItem @typeparam TItem
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@{
// Recompute the roving-tabindex target for this render pass: exactly one
// treeitem carries tabindex="0" — the remembered node while it is still
// visible, else the current selection, else the first visible node.
_tabbableKey = ResolveTabbableKey();
}
@if (_items is null || _items.Count == 0) @if (_items is null || _items.Count == 0)
{ {
if (EmptyContent != null) if (EmptyContent != null)
@@ -11,10 +18,20 @@
} }
else else
{ {
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")"> @* The inline onkeydown below is a deliberate, minimal NATIVE handler, not a Blazor
@foreach (var item in _items) keydown directive attribute. Blazor's preventDefault flag is all-or-nothing per
element, so putting it on the treeitem would both trap Tab inside the tree and
cancel Enter/Space activation of any button a consumer renders in NodeContent.
This instead suppresses only the browser's scroll-on-Space / scroll-on-Arrow
default, and only when the treeitem <li> itself is the event target. It lives on
the root <ul> because the <li> already carries a Blazor keydown handler and a
render tree cannot hold two attributes of the same name. This comment MUST stay
OUTSIDE the start tag (see the chevron comment below for why). *@
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")"
onkeydown="if(event.target.getAttribute('role')!=='treeitem')return;if(event.key===' '||event.key==='Spacebar'||event.key==='ArrowUp'||event.key==='ArrowDown'||event.key==='ArrowLeft'||event.key==='ArrowRight'||event.key==='Home'||event.key==='End')event.preventDefault();">
@for (var i = 0; i < _items.Count; i++)
{ {
RenderNode(item, 0); RenderNode(_items[i], 0, i + 1, _items.Count);
} }
</ul> </ul>
} }
@@ -29,12 +46,13 @@ else
</div> </div>
} }
@{ void RenderNode(TItem item, int depth) @{ void RenderNode(TItem item, int depth, int posInSet, int setSize)
{ {
var key = KeySelector(item); var key = KeySelector(item);
var keyStr = KeyStr(key);
var children = ChildrenSelector(item); var children = ChildrenSelector(item);
var isBranch = HasChildrenSelector(item); var isBranch = HasChildrenSelector(item);
var isExpanded = _expandedKeys.Contains(KeyStr(key)); var isExpanded = _expandedKeys.Contains(keyStr);
var isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key); var isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key);
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : ""); var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
@@ -44,9 +62,23 @@ else
? ComputeCheckState(item) ? ComputeCheckState(item)
: CheckState.Unchecked; : CheckState.Unchecked;
// aria-selected is only meaningful on a selectable tree; in Checkbox mode
// the checked state (not the highlight) is the selection.
var ariaSelected = SelectionMode == TreeViewSelectionMode.Checkbox
? (checkState == CheckState.Checked ? "true" : "false")
: Selectable ? (isSelected ? "true" : "false") : null;
<li role="treeitem" @key="key" <li role="treeitem" @key="key"
data-test="tv-node"
@ref="_nodeRefs[keyStr]"
tabindex="@(keyStr == _tabbableKey ? "0" : "-1")"
aria-level="@(depth + 1)"
aria-posinset="@posInSet"
aria-setsize="@setSize"
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)" aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
aria-selected="@(isSelected ? "true" : null)"> aria-selected="@ariaSelected"
@onkeydown="(e) => OnNodeKeyDown(e, item)"
@onkeydown:stopPropagation>
<div class="@rowClasses" style="padding-left: @(depth * IndentPx)px; --tv-depth: @depth;" <div class="@rowClasses" style="padding-left: @(depth * IndentPx)px; --tv-depth: @depth;"
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)" @oncontextmenu:stopPropagation="@(ContextMenu != null)"> @oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)" @oncontextmenu:stopPropagation="@(ContextMenu != null)">
@if (isBranch) @if (isBranch)
@@ -54,42 +86,49 @@ else
@* preventDefault on the toggle's keydown suppresses Space-bar page scroll on activation @* preventDefault on the toggle's keydown suppresses Space-bar page scroll on activation
(intended for a role=button). It also suppresses Arrow-key default scroll while the toggle (intended for a role=button). It also suppresses Arrow-key default scroll while the toggle
itself has focus — acceptable, as the chevron is Tab-navigated and Blazor offers no per-key itself has focus — acceptable, as the chevron is Tab-navigated and Blazor offers no per-key
conditional preventDefault. This comment MUST stay OUTSIDE the start tag: a Razor comment conditional preventDefault. stopPropagation keeps the chevron's own Enter/Space from also
placed between attributes is emitted as an invalid attribute name and crashes the render reaching the treeitem's keyboard handler (which would activate/select the node).
(InvalidCharacterError → circuit teardown on any tree with branch nodes). *@ This comment MUST stay OUTSIDE the start tag: a Razor comment placed between attributes is
emitted as an invalid attribute name and crashes the render (InvalidCharacterError →
circuit teardown on any tree with branch nodes). *@
<span class="tv-toggle" <span class="tv-toggle"
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="@((isExpanded ? "Collapse " : "Expand ") + KeyStr(key))" aria-label="@((isExpanded ? "Collapse " : "Expand ") + keyStr)"
aria-expanded="@(isExpanded ? "true" : "false")" aria-expanded="@(isExpanded ? "true" : "false")"
@onclick="() => ToggleExpand(key)" @onclick="() => ToggleExpand(key)"
@onclick:stopPropagation @onclick:stopPropagation
@onkeydown="(e) => OnToggleKey(e, key)" @onkeydown="(e) => OnToggleKey(e, key)"
@onkeydown:preventDefault><i class="bi bi-chevron-right"></i></span> @onkeydown:preventDefault
@onkeydown:stopPropagation><i class="bi bi-chevron-right"></i></span>
} }
else else
{ {
<span class="tv-spacer"></span> <span class="tv-spacer"></span>
} }
@* The checkbox and the content slot both stop keydown propagation so the
treeitem's handler only ever sees keys pressed on the node row itself —
a consumer's buttons/inputs inside NodeContent keep their own key handling. *@
@if (SelectionMode == TreeViewSelectionMode.Checkbox) @if (SelectionMode == TreeViewSelectionMode.Checkbox)
{ {
<input type="checkbox" <input type="checkbox"
class="form-check-input tv-checkbox @(checkState == CheckState.Indeterminate ? "tv-checkbox-indeterminate" : "")" class="form-check-input tv-checkbox @(checkState == CheckState.Indeterminate ? "tv-checkbox-indeterminate" : "")"
@ref="_checkboxRefs[KeyStr(key)]" @ref="_checkboxRefs[keyStr]"
checked="@(checkState == CheckState.Checked)" checked="@(checkState == CheckState.Checked)"
@onchange="() => OnCheckboxToggle(item)" @onchange="() => OnCheckboxToggle(item)"
@onclick:stopPropagation /> @onclick:stopPropagation
@onkeydown:stopPropagation />
} }
<span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation> <span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation @onkeydown:stopPropagation>
@NodeContent(item) @NodeContent(item)
</span> </span>
</div> </div>
@if (isBranch && isExpanded && children is { Count: > 0 }) @if (isBranch && isExpanded && children is { Count: > 0 })
{ {
<ul role="group"> <ul role="group">
@foreach (var child in children) @for (var i = 0; i < children.Count; i++)
{ {
RenderNode(child, depth + 1); RenderNode(children[i], depth + 1, i + 1, children.Count);
} }
</ul> </ul>
} }
@@ -112,6 +151,17 @@ else
private bool _contextMenuNeedsFocus; private bool _contextMenuNeedsFocus;
private ElementReference _contextMenuRef; private ElementReference _contextMenuRef;
// ── Roving tabindex (WAI-ARIA tree pattern) ─────────────────────────────
// Exactly one treeitem is tabbable at a time. `_focusedKey` is the node the
// user last landed on; `_tabbableKey` is the key that actually got
// tabindex="0" in the current render (it falls back when `_focusedKey` is
// no longer visible). `_focusNeedsApply` requests a FocusAsync after the
// next render so browser focus follows a keyboard move.
private string? _focusedKey;
private string? _tabbableKey;
private bool _focusNeedsApply;
private readonly Dictionary<string, ElementReference> _nodeRefs = new();
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = []; [Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!; [Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
[Parameter, EditorRequired] public Func<TItem, bool> HasChildrenSelector { get; set; } = default!; [Parameter, EditorRequired] public Func<TItem, bool> HasChildrenSelector { get; set; } = default!;
@@ -174,6 +224,21 @@ else
catch (InvalidOperationException) { } catch (InvalidOperationException) { }
} }
if (_focusNeedsApply)
{
_focusNeedsApply = false;
// Same expected-failure set as the context menu above: the node may
// have been removed by a concurrent re-render, or the circuit may be
// gone. Under bUnit there is no real focus either, so this is a no-op.
if (_tabbableKey != null && _nodeRefs.TryGetValue(_tabbableKey, out var nodeRef))
{
try { await nodeRef.FocusAsync(); }
catch (Microsoft.JSInterop.JSException) { }
catch (Microsoft.JSInterop.JSDisconnectedException) { }
catch (InvalidOperationException) { }
}
}
if (firstRender && StorageKey != null) if (firstRender && StorageKey != null)
{ {
string? json = null; string? json = null;
@@ -300,6 +365,189 @@ else
} }
} }
// ── Keyboard navigation (WAI-ARIA tree pattern) ─────────────────────────
/// <summary>
/// Flatten the tree into the order the user actually sees: every node followed
/// by its children, but children only when the node is an expanded branch that
/// has any. Mirrors the visibility rules in <c>RenderNode</c> — the two must
/// stay in step, or arrow navigation will skip or invent rows.
/// </summary>
private List<(TItem Item, string Key)> BuildVisibleNodes()
{
var sink = new List<(TItem Item, string Key)>();
if (_items is { Count: > 0 })
{
CollectVisibleNodes(_items, sink);
}
return sink;
}
private void CollectVisibleNodes(IReadOnlyList<TItem> items, List<(TItem Item, string Key)> sink)
{
foreach (var item in items)
{
var key = KeyStr(KeySelector(item));
sink.Add((item, key));
var children = ChildrenSelector(item);
if (HasChildrenSelector(item) && _expandedKeys.Contains(key) && children is { Count: > 0 })
{
CollectVisibleNodes(children, sink);
}
}
}
/// <summary>
/// Pick the single node that carries <c>tabindex="0"</c>: the remembered focus
/// target while it is still visible, otherwise the selected node, otherwise the
/// first visible node. Returns null when the tree renders nothing.
/// </summary>
private string? ResolveTabbableKey()
{
var visible = BuildVisibleNodes();
if (visible.Count == 0)
{
return null;
}
if (_focusedKey != null && visible.Any(v => v.Key == _focusedKey))
{
return _focusedKey;
}
if (SelectedKey != null)
{
var selected = KeyStr(SelectedKey);
if (visible.Any(v => v.Key == selected))
{
return selected;
}
}
return visible[0].Key;
}
/// <summary>Make the given node the roving-tabindex target and pull browser focus to it.</summary>
private void MoveFocusTo(string key)
{
_focusedKey = key;
_focusNeedsApply = true;
}
private async Task OnNodeKeyDown(KeyboardEventArgs e, TItem item)
{
var visible = BuildVisibleNodes();
var key = KeyStr(KeySelector(item));
var index = visible.FindIndex(v => v.Key == key);
if (index < 0)
{
return;
}
switch (e.Key)
{
case "ArrowDown":
if (index + 1 < visible.Count)
{
MoveFocusTo(visible[index + 1].Key);
}
break;
case "ArrowUp":
if (index > 0)
{
MoveFocusTo(visible[index - 1].Key);
}
break;
case "ArrowRight":
MoveRight(item, key, visible, index);
break;
case "ArrowLeft":
MoveLeft(key);
break;
case "Home":
MoveFocusTo(visible[0].Key);
break;
case "End":
MoveFocusTo(visible[^1].Key);
break;
case "Enter":
case " ":
case "Spacebar":
await ActivateNode(item);
break;
}
}
/// <summary>
/// ArrowRight: a collapsed branch expands (focus stays put); an already-expanded
/// branch moves focus to its first child; a leaf does nothing.
/// </summary>
private void MoveRight(TItem item, string key, List<(TItem Item, string Key)> visible, int index)
{
if (!HasChildrenSelector(item))
{
return;
}
if (!_expandedKeys.Contains(key))
{
_expandedKeys.Add(key);
PersistExpandedState();
return;
}
// Expanded already: the first child is, by construction, the next entry in
// the flattened visible order.
var children = ChildrenSelector(item);
if (children is { Count: > 0 } && index + 1 < visible.Count)
{
MoveFocusTo(visible[index + 1].Key);
}
}
/// <summary>
/// ArrowLeft: an expanded branch collapses (focus stays put); anything else moves
/// focus to its parent. A root-level node with nothing to collapse does nothing.
/// </summary>
private void MoveLeft(string key)
{
if (_expandedKeys.Contains(key))
{
_expandedKeys.Remove(key);
PersistExpandedState();
return;
}
var parentLookup = BuildParentLookup();
if (parentLookup.TryGetValue(key, out var parentKey) && parentKey != null)
{
MoveFocusTo(parentKey);
}
}
/// <summary>
/// Enter / Space activation — routed to exactly the same path a mouse click on the
/// node content would take, so selection semantics never diverge between input modes.
/// </summary>
private async Task ActivateNode(TItem item)
{
if (SelectionMode == TreeViewSelectionMode.Checkbox)
{
await OnCheckboxToggle(item);
}
else
{
await OnContentClick(KeySelector(item));
}
}
private void PersistExpandedState() private void PersistExpandedState()
{ {
if (StorageKey != null) if (StorageKey != null)
@@ -311,6 +559,10 @@ else
private async Task OnContentClick(object key) private async Task OnContentClick(object key)
{ {
// Clicking a node makes it the tab entry point (roving tabindex follows the
// pointer), but we do not steal browser focus — the click already placed it.
_focusedKey = KeyStr(key);
if (Selectable) if (Selectable)
{ {
await SelectedKeyChanged.InvokeAsync(key); await SelectedKeyChanged.InvokeAsync(key);
@@ -88,6 +88,16 @@
box-shadow: inset 0 0 0 2px var(--bs-primary); box-shadow: inset 0 0 0 2px var(--bs-primary);
} }
/* WAI-ARIA roving tabindex puts keyboard focus on the treeitem <li>, not the row div,
so mirror the same ring onto the row the focused node owns. */
li[role="treeitem"]:focus {
outline: none;
}
li[role="treeitem"]:focus-visible > .tv-row {
box-shadow: inset 0 0 0 2px var(--bs-primary);
}
/* V3 — drop-target (valid). Overrides hover/selected. */ /* V3 — drop-target (valid). Overrides hover/selected. */
.tv-row.tv-drop-target { .tv-row.tv-drop-target {
background-color: rgba(var(--bs-info-rgb), 0.25); background-color: rgba(var(--bs-info-rgb), 0.25);
@@ -0,0 +1,542 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>
/// bUnit tests for <c>TreeView</c>'s WAI-ARIA tree keyboard navigation
/// (https://www.w3.org/WAI/ARIA/apg/patterns/treeview/). Covers the roving
/// tabindex (exactly one tabbable treeitem), Arrow/Home/End movement over the
/// flattened <em>visible</em> node order, ArrowRight/ArrowLeft expand-collapse
/// semantics, Enter/Space activation in both selection modes, and the
/// <c>aria-level</c> / <c>aria-posinset</c> / <c>aria-setsize</c> structure.
///
/// bUnit moves no real browser focus, so "focus" is asserted through the rendered
/// <c>tabindex</c> attribute (the roving-tabindex target) and through the
/// selection callbacks, not through a focused element.
/// </summary>
public class TreeViewKeyboardNavigationTests : BunitContext
{
private record TestNode(string Key, string Label, List<TestNode> Children);
// Alpha (branch)
// ├── Alpha-1 (leaf)
// └── Alpha-2 (branch)
// └── Alpha-2-X (leaf)
// Beta (leaf)
private static List<TestNode> SimpleRoots() => new()
{
new("a", "Alpha", new()
{
new("a1", "Alpha-1", new()),
new("a2", "Alpha-2", new()
{
new("a2x", "Alpha-2-X", new())
})
}),
new("b", "Beta", new()),
};
private IRenderedComponent<TreeView<TestNode>> RenderTreeView(
List<TestNode>? items = null,
Func<TestNode, bool>? initiallyExpanded = null,
bool selectable = false,
object? selectedKey = null,
Action<object?>? onSelectedKeyChanged = null,
TreeViewSelectionMode selectionMode = TreeViewSelectionMode.Single,
HashSet<object>? selectedKeys = null,
Action<HashSet<object>>? onSelectedKeysChanged = null)
{
if (selectionMode == TreeViewSelectionMode.Checkbox)
{
// Checkbox mode pushes the tri-state via JS interop after every render;
// stub it so bUnit's strict mode doesn't blow up on the unmocked call.
JSInterop.SetupVoid("treeviewStorage.setIndeterminate", _ => true);
}
return Render<TreeView<TestNode>>(parameters =>
{
parameters
.Add(p => p.Items, items ?? SimpleRoots())
.Add(p => p.ChildrenSelector, n => n.Children)
.Add(p => p.HasChildrenSelector, n => n.Children.Count > 0)
.Add(p => p.KeySelector, n => n.Key)
.Add(p => p.NodeContent, node => builder =>
{
builder.AddMarkupContent(0, $"<span class=\"node-label\">{node.Label}</span>");
})
.Add(p => p.InitiallyExpanded, initiallyExpanded)
.Add(p => p.Selectable, selectable)
.Add(p => p.SelectedKey, selectedKey)
.Add(p => p.SelectionMode, selectionMode)
.Add(p => p.SelectedKeys, selectedKeys);
if (onSelectedKeyChanged != null)
{
parameters.Add(p => p.SelectedKeyChanged, onSelectedKeyChanged);
}
if (onSelectedKeysChanged != null)
{
parameters.Add(p => p.SelectedKeysChanged, onSelectedKeysChanged);
}
});
}
// `li[role=treeitem]` elements come back in document order, which for a nested
// <ul>/<li> tree is exactly the flattened visible order the arrow keys walk.
// So an index into this list is an index into the visible node sequence.
private static IReadOnlyList<AngleSharp.Dom.IElement> TreeItems(
IRenderedComponent<TreeView<TestNode>> cut)
=> cut.FindAll("li[role='treeitem']").ToList();
/// <summary>Index of the single tabbable node; also asserts that exactly one exists.</summary>
private static int TabbableIndex(IRenderedComponent<TreeView<TestNode>> cut)
{
var items = TreeItems(cut);
var found = -1;
for (var i = 0; i < items.Count; i++)
{
var tabindex = items[i].GetAttribute("tabindex");
if (tabindex == "0")
{
Assert.Equal(-1, found);
found = i;
}
else
{
// Every other node must be explicitly removed from the tab order.
Assert.Equal("-1", tabindex);
}
}
Assert.NotEqual(-1, found);
return found;
}
private static void PressKey(
IRenderedComponent<TreeView<TestNode>> cut, int nodeIndex, string key)
=> TreeItems(cut)[nodeIndex].KeyDown(new KeyboardEventArgs { Key = key });
// ── Roving tabindex ────────────────────────────────────────────────────────
[Fact]
public void InitialRender_ExactlyOneNodeIsTabbable_AndItIsTheFirst()
{
var cut = RenderTreeView();
// Two collapsed roots; Alpha (index 0) owns the tab stop.
Assert.Equal(2, TreeItems(cut).Count);
Assert.Equal(0, TabbableIndex(cut));
}
[Fact]
public void InitialRender_SelectedNode_OwnsTheTabStop()
{
// With a selection present the tab stop defaults to it, not to the first node.
var cut = RenderTreeView(selectable: true, selectedKey: "b");
Assert.Equal(1, TabbableIndex(cut));
}
[Fact]
public void TabStop_FallsBackToFirstNode_WhenFocusedNodeIsCollapsedAway()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
// Visible: Alpha, Alpha-1, Alpha-2, Beta. Move onto Alpha-1.
PressKey(cut, 0, "ArrowDown");
Assert.Equal(1, TabbableIndex(cut));
// Collapsing Alpha removes Alpha-1 from the visible set; the tab stop must
// not vanish with it.
cut.InvokeAsync(() => cut.Instance.CollapseAll());
Assert.Equal(0, TabbableIndex(cut));
}
// ── ArrowDown / ArrowUp ────────────────────────────────────────────────────
[Fact]
public void ArrowDown_MovesToNextVisibleNode()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
// Visible order: Alpha(0), Alpha-1(1), Alpha-2(2), Beta(3).
Assert.Equal(4, TreeItems(cut).Count);
PressKey(cut, 0, "ArrowDown");
Assert.Equal(1, TabbableIndex(cut));
PressKey(cut, 1, "ArrowDown");
Assert.Equal(2, TabbableIndex(cut));
}
[Fact]
public void ArrowDown_SkipsChildrenOfCollapsedBranch()
{
var cut = RenderTreeView();
// Alpha is collapsed, so the next visible node is Beta — not Alpha-1.
PressKey(cut, 0, "ArrowDown");
Assert.Equal(2, TreeItems(cut).Count);
Assert.Equal(1, TabbableIndex(cut));
}
[Fact]
public void ArrowDown_AtLastVisibleNode_IsNoOp()
{
var cut = RenderTreeView();
// Walk onto Beta (the last visible node) first, then try to go past it.
PressKey(cut, 0, "ArrowDown");
PressKey(cut, 1, "ArrowDown");
Assert.Equal(1, TabbableIndex(cut));
}
[Fact]
public void ArrowUp_MovesToPreviousVisibleNode()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
PressKey(cut, 3, "ArrowUp");
Assert.Equal(2, TabbableIndex(cut));
PressKey(cut, 2, "ArrowUp");
Assert.Equal(1, TabbableIndex(cut));
}
[Fact]
public void ArrowUp_AtFirstVisibleNode_IsNoOp()
{
var cut = RenderTreeView();
PressKey(cut, 0, "ArrowUp");
Assert.Equal(0, TabbableIndex(cut));
}
// ── ArrowRight ─────────────────────────────────────────────────────────────
[Fact]
public void ArrowRight_OnCollapsedBranch_Expands_WithoutMovingFocus()
{
var cut = RenderTreeView();
PressKey(cut, 0, "ArrowRight");
var items = TreeItems(cut);
Assert.Equal("true", items[0].GetAttribute("aria-expanded"));
Assert.Equal(4, items.Count); // Alpha, Alpha-1, Alpha-2, Beta
Assert.Equal(0, TabbableIndex(cut));
}
[Fact]
public void ArrowRight_OnExpandedBranch_MovesToFirstChild()
{
var cut = RenderTreeView();
PressKey(cut, 0, "ArrowRight"); // expand
PressKey(cut, 0, "ArrowRight"); // move onto Alpha-1
Assert.Equal(1, TabbableIndex(cut));
}
[Fact]
public void ArrowRight_OnLeaf_IsNoOp()
{
var cut = RenderTreeView();
// Beta is a leaf: no expansion, no movement, no new nodes.
PressKey(cut, 0, "ArrowDown");
PressKey(cut, 1, "ArrowRight");
var items = TreeItems(cut);
Assert.Equal(2, items.Count);
Assert.Null(items[1].GetAttribute("aria-expanded"));
Assert.Equal(1, TabbableIndex(cut));
}
// ── ArrowLeft ──────────────────────────────────────────────────────────────
[Fact]
public void ArrowLeft_OnExpandedBranch_Collapses_WithoutMovingFocus()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
PressKey(cut, 0, "ArrowLeft");
var items = TreeItems(cut);
Assert.Equal("false", items[0].GetAttribute("aria-expanded"));
Assert.Equal(2, items.Count);
Assert.Equal(0, TabbableIndex(cut));
}
[Fact]
public void ArrowLeft_OnChildNode_MovesToParent()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
// Alpha-1 (index 1) is a collapsed leaf → ArrowLeft goes up to Alpha.
PressKey(cut, 1, "ArrowLeft");
Assert.Equal(0, TabbableIndex(cut));
// The parent must NOT have collapsed as a side effect.
Assert.Equal("true", TreeItems(cut)[0].GetAttribute("aria-expanded"));
}
[Fact]
public void ArrowLeft_OnCollapsedBranchChild_MovesToParent()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
// Alpha-2 (index 2) is a branch but collapsed → move to its parent Alpha.
PressKey(cut, 2, "ArrowLeft");
Assert.Equal(0, TabbableIndex(cut));
}
[Fact]
public void ArrowLeft_OnRootLeaf_IsNoOp()
{
var cut = RenderTreeView();
PressKey(cut, 0, "ArrowDown");
PressKey(cut, 1, "ArrowLeft"); // Beta — root level, nothing to collapse
Assert.Equal(2, TreeItems(cut).Count);
Assert.Equal(1, TabbableIndex(cut));
}
// ── Home / End ─────────────────────────────────────────────────────────────
[Fact]
public void Home_MovesToFirstVisibleNode()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
PressKey(cut, 3, "Home");
Assert.Equal(0, TabbableIndex(cut));
}
[Fact]
public void End_MovesToLastVisibleNode()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2");
// Visible: Alpha(0), Alpha-1(1), Alpha-2(2), Alpha-2-X(3), Beta(4).
Assert.Equal(5, TreeItems(cut).Count);
PressKey(cut, 0, "End");
Assert.Equal(4, TabbableIndex(cut));
}
// ── Enter / Space activation ───────────────────────────────────────────────
[Fact]
public void Enter_SelectsNode_InSingleMode()
{
object? selected = null;
var cut = RenderTreeView(
initiallyExpanded: n => n.Key == "a",
selectable: true,
onSelectedKeyChanged: k => selected = k);
PressKey(cut, 1, "Enter"); // Alpha-1
Assert.Equal("a1", selected);
}
[Fact]
public void Space_SelectsNode_InSingleMode()
{
object? selected = null;
var cut = RenderTreeView(
selectable: true,
onSelectedKeyChanged: k => selected = k);
PressKey(cut, 1, " "); // Beta
Assert.Equal("b", selected);
}
[Fact]
public void Enter_WhenNotSelectable_RaisesNoCallback()
{
object? selected = null;
var cut = RenderTreeView(selectable: false, onSelectedKeyChanged: k => selected = k);
PressKey(cut, 0, "Enter");
Assert.Null(selected);
}
[Fact]
public void Space_InCheckboxMode_TogglesTheNode()
{
HashSet<object>? captured = null;
var cut = RenderTreeView(
initiallyExpanded: _ => true,
selectionMode: TreeViewSelectionMode.Checkbox,
selectedKeys: new HashSet<object>(),
onSelectedKeysChanged: keys => captured = keys);
// Visible: Alpha(0), Alpha-1(1), Alpha-2(2), Alpha-2-X(3), Beta(4).
// Space on Alpha-1 selects that one leaf.
PressKey(cut, 1, " ");
Assert.NotNull(captured);
Assert.Equal(new HashSet<object> { "a1" }, captured);
}
[Fact]
public void Enter_InCheckboxMode_OnFolder_CascadesToDescendantLeaves()
{
HashSet<object>? captured = null;
var cut = RenderTreeView(
initiallyExpanded: _ => true,
selectionMode: TreeViewSelectionMode.Checkbox,
selectedKeys: new HashSet<object>(),
onSelectedKeysChanged: keys => captured = keys);
PressKey(cut, 0, "Enter"); // Alpha → leaves a1 + a2x
Assert.NotNull(captured);
Assert.Equal(2, captured!.Count);
Assert.Contains((object)"a1", captured);
Assert.Contains((object)"a2x", captured);
}
[Fact]
public void Keydown_OnNestedNode_DoesNotAlsoActivateItsAncestor()
{
// The treeitem stops keydown propagation, so a key pressed on a child must
// not be handled a second time by every enclosing <li>.
object? selected = null;
var cut = RenderTreeView(
initiallyExpanded: n => n.Key == "a",
selectable: true,
onSelectedKeyChanged: k => selected = k);
PressKey(cut, 1, "Enter"); // Alpha-1, nested inside Alpha
Assert.Equal("a1", selected);
}
// ── Existing chevron behaviour (regression) ────────────────────────────────
[Fact]
public void ChevronToggle_EnterKey_StillExpands_AndDoesNotSelect()
{
object? selected = null;
var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k);
cut.Find(".tv-toggle").KeyDown(new KeyboardEventArgs { Key = "Enter" });
Assert.Equal("true", TreeItems(cut)[0].GetAttribute("aria-expanded"));
// The chevron stops propagation, so the treeitem never sees the Enter.
Assert.Null(selected);
}
[Fact]
public void ChevronToggle_SpaceKey_StillExpands()
{
var cut = RenderTreeView();
cut.Find(".tv-toggle").KeyDown(new KeyboardEventArgs { Key = " " });
Assert.Equal("true", TreeItems(cut)[0].GetAttribute("aria-expanded"));
}
// ── ARIA structure ─────────────────────────────────────────────────────────
[Fact]
public void TreeItems_CarryOneBasedAriaLevel()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a" || n.Key == "a2");
var items = TreeItems(cut);
// Alpha, Alpha-1, Alpha-2, Alpha-2-X, Beta
Assert.Equal("1", items[0].GetAttribute("aria-level"));
Assert.Equal("2", items[1].GetAttribute("aria-level"));
Assert.Equal("2", items[2].GetAttribute("aria-level"));
Assert.Equal("3", items[3].GetAttribute("aria-level"));
Assert.Equal("1", items[4].GetAttribute("aria-level"));
}
[Fact]
public void TreeItems_CarryAriaPosInSetAndSetSize()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
var items = TreeItems(cut);
// Two roots.
Assert.Equal("1", items[0].GetAttribute("aria-posinset"));
Assert.Equal("2", items[0].GetAttribute("aria-setsize"));
// Alpha's two children.
Assert.Equal("1", items[1].GetAttribute("aria-posinset"));
Assert.Equal("2", items[1].GetAttribute("aria-setsize"));
Assert.Equal("2", items[2].GetAttribute("aria-posinset"));
Assert.Equal("2", items[2].GetAttribute("aria-setsize"));
// Beta — second of the two roots.
Assert.Equal("2", items[3].GetAttribute("aria-posinset"));
Assert.Equal("2", items[3].GetAttribute("aria-setsize"));
}
[Fact]
public void TreeStructure_UsesRoleTree_RoleGroup_AndRoleTreeitem()
{
var cut = RenderTreeView(initiallyExpanded: n => n.Key == "a");
Assert.NotNull(cut.Find("ul[role='tree']"));
Assert.Single(cut.FindAll("ul[role='group']"));
Assert.Equal(4, TreeItems(cut).Count);
}
[Fact]
public void AriaSelected_IsPresentOnSelectableNodes_AndTracksSelection()
{
var cut = RenderTreeView(selectable: true, selectedKey: "a");
var items = TreeItems(cut);
Assert.Equal("true", items[0].GetAttribute("aria-selected"));
Assert.Equal("false", items[1].GetAttribute("aria-selected"));
}
[Fact]
public void RootTree_SuppressesBrowserScrollDefaults_ForNavigationKeys()
{
// Space/Arrow/Home/End would otherwise scroll the page while a treeitem holds
// focus. bUnit runs no scripts, so this locks the native guard's presence and
// the key set it covers — not its runtime effect.
var cut = RenderTreeView();
var handler = cut.Find("ul[role='tree']").GetAttribute("onkeydown");
Assert.NotNull(handler);
Assert.Contains("preventDefault", handler!);
// Scoped to the treeitem itself so a consumer's buttons inside NodeContent keep
// their Enter/Space activation.
Assert.Contains("'treeitem'", handler);
foreach (var key in new[] { "' '", "'ArrowUp'", "'ArrowDown'", "'ArrowLeft'", "'ArrowRight'", "'Home'", "'End'" })
{
Assert.Contains(key, handler);
}
}
[Fact]
public void AriaSelected_IsAbsent_WhenTreeIsNotSelectable()
{
var cut = RenderTreeView(selectable: false);
Assert.Null(TreeItems(cut)[0].GetAttribute("aria-selected"));
}
}