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:
@@ -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.
|
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 `<li>` (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 `<ul>` 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 `<li>` 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
|
## 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 `<li>` 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 `<li>` 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.
|
`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.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
@typeparam TItem
|
@typeparam TItem
|
||||||
@inject IJSRuntime JSRuntime
|
@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 (_items is null || _items.Count == 0)
|
||||||
{
|
{
|
||||||
if (EmptyContent != null)
|
if (EmptyContent != null)
|
||||||
@@ -11,10 +18,20 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<ul role="tree" class="tv-root @(ShowGuideLines ? "tv-guides" : "")">
|
@* The inline onkeydown below is a deliberate, minimal NATIVE handler, not a Blazor
|
||||||
@foreach (var item in _items)
|
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>
|
</ul>
|
||||||
}
|
}
|
||||||
@@ -29,12 +46,13 @@ else
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@{ void RenderNode(TItem item, int depth)
|
@{ void RenderNode(TItem item, int depth, int posInSet, int setSize)
|
||||||
{
|
{
|
||||||
var key = KeySelector(item);
|
var key = KeySelector(item);
|
||||||
|
var keyStr = KeyStr(key);
|
||||||
var children = ChildrenSelector(item);
|
var children = ChildrenSelector(item);
|
||||||
var isBranch = HasChildrenSelector(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 isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key);
|
||||||
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
|
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
|
||||||
|
|
||||||
@@ -44,9 +62,23 @@ else
|
|||||||
? ComputeCheckState(item)
|
? ComputeCheckState(item)
|
||||||
: CheckState.Unchecked;
|
: 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"
|
<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-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;"
|
<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)">
|
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)" @oncontextmenu:stopPropagation="@(ContextMenu != null)">
|
||||||
@if (isBranch)
|
@if (isBranch)
|
||||||
@@ -54,42 +86,49 @@ else
|
|||||||
@* preventDefault on the toggle's keydown suppresses Space-bar page scroll on activation
|
@* 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
|
(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
|
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
|
conditional preventDefault. stopPropagation keeps the chevron's own Enter/Space from also
|
||||||
placed between attributes is emitted as an invalid attribute name and crashes the render
|
reaching the treeitem's keyboard handler (which would activate/select the node).
|
||||||
(InvalidCharacterError → circuit teardown on any tree with branch nodes). *@
|
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"
|
<span class="tv-toggle"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="@((isExpanded ? "Collapse " : "Expand ") + KeyStr(key))"
|
aria-label="@((isExpanded ? "Collapse " : "Expand ") + keyStr)"
|
||||||
aria-expanded="@(isExpanded ? "true" : "false")"
|
aria-expanded="@(isExpanded ? "true" : "false")"
|
||||||
@onclick="() => ToggleExpand(key)"
|
@onclick="() => ToggleExpand(key)"
|
||||||
@onclick:stopPropagation
|
@onclick:stopPropagation
|
||||||
@onkeydown="(e) => OnToggleKey(e, key)"
|
@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
|
else
|
||||||
{
|
{
|
||||||
<span class="tv-spacer"></span>
|
<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)
|
@if (SelectionMode == TreeViewSelectionMode.Checkbox)
|
||||||
{
|
{
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
class="form-check-input tv-checkbox @(checkState == CheckState.Indeterminate ? "tv-checkbox-indeterminate" : "")"
|
class="form-check-input tv-checkbox @(checkState == CheckState.Indeterminate ? "tv-checkbox-indeterminate" : "")"
|
||||||
@ref="_checkboxRefs[KeyStr(key)]"
|
@ref="_checkboxRefs[keyStr]"
|
||||||
checked="@(checkState == CheckState.Checked)"
|
checked="@(checkState == CheckState.Checked)"
|
||||||
@onchange="() => OnCheckboxToggle(item)"
|
@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)
|
@NodeContent(item)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@if (isBranch && isExpanded && children is { Count: > 0 })
|
@if (isBranch && isExpanded && children is { Count: > 0 })
|
||||||
{
|
{
|
||||||
<ul role="group">
|
<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>
|
</ul>
|
||||||
}
|
}
|
||||||
@@ -112,6 +151,17 @@ else
|
|||||||
private bool _contextMenuNeedsFocus;
|
private bool _contextMenuNeedsFocus;
|
||||||
private ElementReference _contextMenuRef;
|
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 IReadOnlyList<TItem> Items { get; set; } = [];
|
||||||
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
|
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
|
||||||
[Parameter, EditorRequired] public Func<TItem, bool> HasChildrenSelector { get; set; } = default!;
|
[Parameter, EditorRequired] public Func<TItem, bool> HasChildrenSelector { get; set; } = default!;
|
||||||
@@ -174,6 +224,21 @@ else
|
|||||||
catch (InvalidOperationException) { }
|
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)
|
if (firstRender && StorageKey != null)
|
||||||
{
|
{
|
||||||
string? json = 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()
|
private void PersistExpandedState()
|
||||||
{
|
{
|
||||||
if (StorageKey != null)
|
if (StorageKey != null)
|
||||||
@@ -311,6 +559,10 @@ else
|
|||||||
|
|
||||||
private async Task OnContentClick(object key)
|
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)
|
if (Selectable)
|
||||||
{
|
{
|
||||||
await SelectedKeyChanged.InvokeAsync(key);
|
await SelectedKeyChanged.InvokeAsync(key);
|
||||||
|
|||||||
@@ -88,6 +88,16 @@
|
|||||||
box-shadow: inset 0 0 0 2px var(--bs-primary);
|
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. */
|
/* V3 — drop-target (valid). Overrides hover/selected. */
|
||||||
.tv-row.tv-drop-target {
|
.tv-row.tv-drop-target {
|
||||||
background-color: rgba(var(--bs-info-rgb), 0.25);
|
background-color: rgba(var(--bs-info-rgb), 0.25);
|
||||||
|
|||||||
+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