feat(centralui): TreeView checkbox-selection mode with tri-state

This commit is contained in:
Joseph Doherty
2026-05-24 05:13:04 -04:00
parent 9a3f5231db
commit e099ed2038
4 changed files with 323 additions and 0 deletions

View File

@@ -38,6 +38,12 @@ else
var isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key);
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
// Checkbox-mode tri-state computed for this node (folder = aggregate of
// descendant leaves; leaf = present-in-SelectedKeys).
var checkState = SelectionMode == TreeViewSelectionMode.Checkbox
? ComputeCheckState(item)
: CheckState.Unchecked;
<li role="treeitem" @key="key"
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
aria-selected="@(isSelected ? "true" : null)">
@@ -51,6 +57,15 @@ else
{
<span class="tv-spacer"></span>
}
@if (SelectionMode == TreeViewSelectionMode.Checkbox)
{
<input type="checkbox"
class="form-check-input tv-checkbox @(checkState == CheckState.Indeterminate ? "tv-checkbox-indeterminate" : "")"
@ref="_checkboxRefs[KeyStr(key)]"
checked="@(checkState == CheckState.Checked)"
@onchange="() => OnCheckboxToggle(item)"
@onclick:stopPropagation />
}
<span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation>
@NodeContent(item)
</span>
@@ -99,6 +114,15 @@ else
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
[Parameter] public string? StorageKey { get; set; }
// ── Checkbox-selection mode (additive; SelectionMode=Single keeps prior behaviour) ──
[Parameter] public TreeViewSelectionMode SelectionMode { get; set; } = TreeViewSelectionMode.Single;
[Parameter] public HashSet<object>? SelectedKeys { get; set; }
[Parameter] public EventCallback<HashSet<object>> SelectedKeysChanged { get; set; }
private readonly Dictionary<string, ElementReference> _checkboxRefs = new();
private enum CheckState { Unchecked, Checked, Indeterminate }
protected override void OnParametersSet()
{
_items = Items;
@@ -182,6 +206,32 @@ else
StateHasChanged();
}
// Apply checkbox tri-state (`indeterminate`) after every render in
// Checkbox mode. Blazor doesn't bind input.indeterminate natively.
if (SelectionMode == TreeViewSelectionMode.Checkbox && _items is { Count: > 0 })
{
await ApplyIndeterminateStateAsync();
}
}
private async Task ApplyIndeterminateStateAsync()
{
foreach (var (keyStr, elemRef) in _checkboxRefs)
{
var item = FindItemByKey(_items!, keyStr);
if (item is null) continue;
var state = ComputeCheckState(item);
try
{
await JSRuntime.InvokeVoidAsync(
"treeviewStorage.setIndeterminate",
elemRef,
state == CheckState.Indeterminate);
}
catch (Microsoft.JSInterop.JSDisconnectedException) { /* circuit gone */ }
catch (Microsoft.JSInterop.JSException) { /* element gone */ }
}
}
private bool KeyExistsInTree(IReadOnlyList<TItem> items, object key)
@@ -362,4 +412,110 @@ else
}
}
}
// ── Checkbox-selection helpers ──────────────────────────────────────────
// Folder = aggregate of its descendant LEAVES (we don't track folder keys
// in SelectedKeys — only leaf keys are persisted). A folder is Checked when
// every descendant leaf is in SelectedKeys, Unchecked when none are, and
// Indeterminate otherwise.
private CheckState ComputeCheckState(TItem item)
{
var children = ChildrenSelector(item);
var isBranch = HasChildrenSelector(item);
if (!isBranch || children is null || children.Count == 0)
{
var leafKey = KeySelector(item);
return SelectedKeys != null && SelectedKeys.Contains(leafKey)
? CheckState.Checked
: CheckState.Unchecked;
}
var total = 0;
var selected = 0;
CountDescendantLeaves(item, ref total, ref selected);
if (total == 0) return CheckState.Unchecked;
if (selected == 0) return CheckState.Unchecked;
if (selected == total) return CheckState.Checked;
return CheckState.Indeterminate;
}
private void CountDescendantLeaves(TItem item, ref int total, ref int selected)
{
var children = ChildrenSelector(item);
var hasChildren = HasChildrenSelector(item) && children is { Count: > 0 };
if (!hasChildren)
{
total++;
if (SelectedKeys != null && SelectedKeys.Contains(KeySelector(item)))
{
selected++;
}
return;
}
foreach (var child in children!)
{
CountDescendantLeaves(child, ref total, ref selected);
}
}
private void CollectDescendantLeafKeys(TItem item, List<object> sink)
{
var children = ChildrenSelector(item);
var hasChildren = HasChildrenSelector(item) && children is { Count: > 0 };
if (!hasChildren)
{
sink.Add(KeySelector(item));
return;
}
foreach (var child in children!)
{
CollectDescendantLeafKeys(child, sink);
}
}
private async Task OnCheckboxToggle(TItem item)
{
// SelectedKeys is the source of truth — copy-on-write so consumers
// observe a fresh reference and Blazor reliably re-renders.
var current = SelectedKeys is null
? new HashSet<object>()
: new HashSet<object>(SelectedKeys);
var leaves = new List<object>();
CollectDescendantLeafKeys(item, leaves);
if (leaves.Count == 0) return;
// Folder-toggle semantics: if every descendant leaf is currently selected,
// uncheck them all; otherwise select all. Leaf nodes have leaves = { self }
// so this simplifies to a plain toggle.
var allSelected = leaves.All(current.Contains);
if (allSelected)
{
foreach (var k in leaves) current.Remove(k);
}
else
{
foreach (var k in leaves) current.Add(k);
}
SelectedKeys = current;
await SelectedKeysChanged.InvokeAsync(current);
}
private TItem? FindItemByKey(IReadOnlyList<TItem> items, string keyStr)
{
foreach (var item in items)
{
if (KeyStr(KeySelector(item)) == keyStr) return item;
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
var found = FindItemByKey(children, keyStr);
if (found is not null) return found;
}
}
return default;
}
}

View File

@@ -0,0 +1,12 @@
namespace ScadaLink.CentralUI.Components.Shared;
/// <summary>
/// Selection mode for <see cref="TreeView{TItem}"/>. <see cref="Single"/> is the
/// default click-to-select behaviour (preserves legacy callers). <see cref="Checkbox"/>
/// renders an input checkbox per node with tri-state propagation on folders.
/// </summary>
public enum TreeViewSelectionMode
{
Single,
Checkbox,
}

View File

@@ -4,5 +4,13 @@ window.treeviewStorage = {
},
load: function (storageKey) {
return sessionStorage.getItem("treeview:" + storageKey);
},
// Blazor cannot bind input.indeterminate natively (only `checked`). The
// TreeView's Checkbox-selection mode calls this from OnAfterRenderAsync to
// toggle the tri-state visual on each render.
setIndeterminate: function (el, value) {
if (el) {
el.indeterminate = !!value;
}
}
};

View File

@@ -0,0 +1,147 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using ScadaLink.CentralUI.Components.Shared;
namespace ScadaLink.CentralUI.Tests;
/// <summary>
/// bUnit tests for TreeView's Checkbox-selection mode (T19 of the Transport
/// feature). Verifies that:
/// - a checkbox renders next to every node in Checkbox mode,
/// - clicking a folder checkbox cascades selection to every descendant leaf,
/// - clicking a leaf produces a partial parent state (Indeterminate),
/// - Single-mode behaviour is preserved (regression).
/// </summary>
public class TreeViewMultiSelectTests : BunitContext
{
private record TestNode(string Key, string Label, List<TestNode> Children);
private static List<TestNode> TwoFoldersThreeLeaves() => new()
{
// Folder1 → Leaf1a, Leaf1b
new("f1", "Folder1", new()
{
new("l1a", "Leaf1a", new()),
new("l1b", "Leaf1b", new()),
}),
// Folder2 → Leaf2a
new("f2", "Folder2", new()
{
new("l2a", "Leaf2a", new()),
}),
};
private IRenderedComponent<TreeView<TestNode>> RenderCheckboxTree(
List<TestNode>? items = null,
HashSet<object>? selectedKeys = null,
Action<HashSet<object>>? onSelectedKeysChanged = null)
{
// Indeterminate is set via JS interop after each render. We 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 ?? TwoFoldersThreeLeaves())
.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.SelectionMode, TreeViewSelectionMode.Checkbox)
.Add(p => p.InitiallyExpanded, _ => true)
.Add(p => p.SelectedKeys, selectedKeys);
if (onSelectedKeysChanged != null)
{
parameters.Add(p => p.SelectedKeysChanged, onSelectedKeysChanged);
}
});
}
[Fact]
public void Checkbox_mode_renders_input_checkbox_per_node()
{
var cut = RenderCheckboxTree();
// 2 folders + 3 leaves = 5 nodes, expanded → 5 checkboxes.
var checkboxes = cut.FindAll("input.tv-checkbox");
Assert.Equal(5, checkboxes.Count);
}
[Fact]
public void Clicking_folder_checkbox_selects_all_descendant_leaves()
{
HashSet<object>? captured = null;
var cut = RenderCheckboxTree(
selectedKeys: new HashSet<object>(),
onSelectedKeysChanged: keys => captured = keys);
// First checkbox is Folder1 (root #1). It has leaves l1a + l1b.
var folderCheckbox = cut.FindAll("input.tv-checkbox")[0];
folderCheckbox.Change(true);
Assert.NotNull(captured);
Assert.Equal(2, captured!.Count);
Assert.Contains((object)"l1a", captured);
Assert.Contains((object)"l1b", captured);
}
[Fact]
public void Clicking_leaf_makes_parent_indeterminate_when_sibling_unchecked()
{
// Pre-select one of Folder1's two leaves → Folder1 should compute as
// Indeterminate on the next render (carries the `tv-checkbox-indeterminate`
// CSS marker class — the JS-set `indeterminate` DOM property is set via
// interop and isn't observable through bUnit's rendered HTML).
var cut = RenderCheckboxTree(
selectedKeys: new HashSet<object> { "l1a" });
// Folder1 checkbox is the first .tv-checkbox; should carry the partial
// marker class.
var folderCheckbox = cut.FindAll("input.tv-checkbox")[0];
var classAttr = folderCheckbox.GetAttribute("class") ?? string.Empty;
Assert.Contains("tv-checkbox-indeterminate", classAttr);
// …and not the fully-checked attribute.
Assert.NotEqual("true", folderCheckbox.GetAttribute("checked"));
}
[Fact]
public void Single_mode_unchanged()
{
// Default SelectionMode is Single. No SelectedKeysChanged should fire,
// and SelectedKeyChanged (singular) should fire on content click.
object? selected = null;
HashSet<object>? bulk = null;
var cut = Render<TreeView<TestNode>>(parameters =>
{
parameters
.Add(p => p.Items, TwoFoldersThreeLeaves())
.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, _ => true)
.Add(p => p.Selectable, true)
.Add(p => p.SelectedKeyChanged, (Action<object?>)(k => selected = k))
.Add(p => p.SelectedKeysChanged, (Action<HashSet<object>>)(s => bulk = s));
});
// No checkboxes rendered in Single mode.
Assert.Empty(cut.FindAll("input.tv-checkbox"));
// Clicking the content fires SelectedKeyChanged (singular) and NOT
// SelectedKeysChanged (plural).
cut.Find(".tv-content").Click();
Assert.Equal("f1", selected);
Assert.Null(bulk);
}
}