feat(centralui): TreeView checkbox-selection mode with tri-state
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
147
tests/ScadaLink.CentralUI.Tests/TreeViewMultiSelectTests.cs
Normal file
147
tests/ScadaLink.CentralUI.Tests/TreeViewMultiSelectTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user