Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs
T
Joseph Doherty fdfd5e1b27 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.
2026-08-01 11:28:20 -04:00

543 lines
19 KiB
C#

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"));
}
}