diff --git a/docs/components/TreeView.md b/docs/components/TreeView.md
index 212fb434..5cb5d5da 100644
--- a/docs/components/TreeView.md
+++ b/docs/components/TreeView.md
@@ -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.
+### 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 `
` (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 `
` 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 `
` 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)` local function that recurses the tree at render time — no intermediate view model is built inside the component. Every `
` 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 `
` 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.
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor
index b7c9cbaf..e5f52320 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor
@@ -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
{
-
- @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
itself is the event target. It lives on
+ the root
because the
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). *@
+
+ @for (var i = 0; i < _items.Count; i++)
{
- RenderNode(item, 0);
+ RenderNode(_items[i], 0, i + 1, _items.Count);
}
}
@@ -29,12 +46,13 @@ else
}
-@{ 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;
+
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). *@
ToggleExpand(key)"
@onclick:stopPropagation
@onkeydown="(e) => OnToggleKey(e, key)"
- @onkeydown:preventDefault>
+ @onkeydown:preventDefault
+ @onkeydown:stopPropagation>
}
else
{
}
+ @* 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)
{
OnCheckboxToggle(item)"
- @onclick:stopPropagation />
+ @onclick:stopPropagation
+ @onkeydown:stopPropagation />
}
- OnContentClick(key)" @onclick:stopPropagation>
+ OnContentClick(key)" @onclick:stopPropagation @onkeydown:stopPropagation>
@NodeContent(item)
@if (isBranch && isExpanded && children is { Count: > 0 })
{
- @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);
}
}
@@ -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 _nodeRefs = new();
+
[Parameter, EditorRequired] public IReadOnlyList Items { get; set; } = [];
[Parameter, EditorRequired] public Func> ChildrenSelector { get; set; } = default!;
[Parameter, EditorRequired] public Func 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) ─────────────────────────
+
+ ///
+ /// 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 RenderNode — the two must
+ /// stay in step, or arrow navigation will skip or invent rows.
+ ///
+ 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 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);
+ }
+ }
+ }
+
+ ///
+ /// Pick the single node that carries tabindex="0": 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.
+ ///
+ 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;
+ }
+
+ /// Make the given node the roving-tabindex target and pull browser focus to it.
+ 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;
+ }
+ }
+
+ ///
+ /// ArrowRight: a collapsed branch expands (focus stays put); an already-expanded
+ /// branch moves focus to its first child; a leaf does nothing.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor.css
index e2579b1a..07686ec1 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor.css
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor.css
@@ -88,6 +88,16 @@
box-shadow: inset 0 0 0 2px var(--bs-primary);
}
+/* WAI-ARIA roving tabindex puts keyboard focus on the treeitem
, 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);
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs
new file mode 100644
index 00000000..05736411
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs
@@ -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;
+
+///
+/// bUnit tests for TreeView'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 visible node order, ArrowRight/ArrowLeft expand-collapse
+/// semantics, Enter/Space activation in both selection modes, and the
+/// aria-level / aria-posinset / aria-setsize structure.
+///
+/// bUnit moves no real browser focus, so "focus" is asserted through the rendered
+/// tabindex attribute (the roving-tabindex target) and through the
+/// selection callbacks, not through a focused element.
+///
+public class TreeViewKeyboardNavigationTests : BunitContext
+{
+ private record TestNode(string Key, string Label, List Children);
+
+ // Alpha (branch)
+ // ├── Alpha-1 (leaf)
+ // └── Alpha-2 (branch)
+ // └── Alpha-2-X (leaf)
+ // Beta (leaf)
+ private static List 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> RenderTreeView(
+ List? items = null,
+ Func? initiallyExpanded = null,
+ bool selectable = false,
+ object? selectedKey = null,
+ Action