From fdfd5e1b2718e2d65142f007aec13fd749ac51b8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 1 Aug 2026 11:26:23 -0400 Subject: [PATCH] feat(centralui): TreeView full WAI-ARIA keyboard navigation (M10 residual R7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TreeView 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. --- docs/components/TreeView.md | 24 +- .../Components/Shared/TreeView.razor | 284 ++++++++- .../Components/Shared/TreeView.razor.css | 10 + .../Shared/TreeViewKeyboardNavigationTests.cs | 542 ++++++++++++++++++ 4 files changed, 843 insertions(+), 17 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/TreeViewKeyboardNavigationTests.cs 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; +
        • + aria-selected="@ariaSelected" + @onkeydown="(e) => OnNodeKeyDown(e, item)" + @onkeydown:stopPropagation>
          @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). *@ + @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) { + @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? onSelectedKeyChanged = null, + TreeViewSelectionMode selectionMode = TreeViewSelectionMode.Single, + HashSet? selectedKeys = null, + Action>? 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>(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, $"{node.Label}"); + }) + .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 + //
            /
          • 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 TreeItems( + IRenderedComponent> cut) + => cut.FindAll("li[role='treeitem']").ToList(); + + /// Index of the single tabbable node; also asserts that exactly one exists. + private static int TabbableIndex(IRenderedComponent> 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> 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? captured = null; + var cut = RenderTreeView( + initiallyExpanded: _ => true, + selectionMode: TreeViewSelectionMode.Checkbox, + selectedKeys: new HashSet(), + 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 { "a1" }, captured); + } + + [Fact] + public void Enter_InCheckboxMode_OnFolder_CascadesToDescendantLeaves() + { + HashSet? captured = null; + var cut = RenderTreeView( + initiallyExpanded: _ => true, + selectionMode: TreeViewSelectionMode.Checkbox, + selectedKeys: new HashSet(), + 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
          • . + 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")); + } +}