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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user