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
@@ -2,6 +2,13 @@
@typeparam TItem
@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 (EmptyContent != null)
@@ -11,10 +18,20 @@
}
else
{
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")">
@foreach (var item in _items)
@* The inline onkeydown below is a deliberate, minimal NATIVE handler, not a Blazor
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>
}
@@ -29,12 +46,13 @@ else
</div>
}
@{ void RenderNode(TItem item, int depth)
@{ void RenderNode(TItem item, int depth, int posInSet, int setSize)
{
var key = KeySelector(item);
var keyStr = KeyStr(key);
var children = ChildrenSelector(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 rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
@@ -44,9 +62,23 @@ else
? ComputeCheckState(item)
: 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"
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-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;"
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)" @oncontextmenu:stopPropagation="@(ContextMenu != null)">
@if (isBranch)
@@ -54,42 +86,49 @@ else
@* 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
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
placed between attributes is emitted as an invalid attribute name and crashes the render
(InvalidCharacterError → circuit teardown on any tree with branch nodes). *@
conditional preventDefault. stopPropagation keeps the chevron's own Enter/Space from also
reaching the treeitem's keyboard handler (which would activate/select the node).
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"
role="button"
tabindex="0"
aria-label="@((isExpanded ? "Collapse " : "Expand ") + KeyStr(key))"
aria-label="@((isExpanded ? "Collapse " : "Expand ") + keyStr)"
aria-expanded="@(isExpanded ? "true" : "false")"
@onclick="() => ToggleExpand(key)"
@onclick:stopPropagation
@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
{
<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)
{
<input type="checkbox"
class="form-check-input tv-checkbox @(checkState == CheckState.Indeterminate ? "tv-checkbox-indeterminate" : "")"
@ref="_checkboxRefs[KeyStr(key)]"
@ref="_checkboxRefs[keyStr]"
checked="@(checkState == CheckState.Checked)"
@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)
</span>
</div>
@if (isBranch && isExpanded && children is { Count: > 0 })
{
<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>
}
@@ -112,6 +151,17 @@ else
private bool _contextMenuNeedsFocus;
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 Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
[Parameter, EditorRequired] public Func<TItem, bool> HasChildrenSelector { get; set; } = default!;
@@ -174,6 +224,21 @@ else
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)
{
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()
{
if (StorageKey != null)
@@ -311,6 +559,10 @@ else
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)
{
await SelectedKeyChanged.InvokeAsync(key);
@@ -88,6 +88,16 @@
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. */
.tv-row.tv-drop-target {
background-color: rgba(var(--bs-info-rgb), 0.25);