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