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 isSelected = Selectable && SelectedKey != null && SelectedKey.Equals(key);
|
||||||
var rowClasses = "tv-row" + (isSelected ? " tv-selected " + SelectedCssClass : "");
|
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"
|
<li role="treeitem" @key="key"
|
||||||
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
|
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
|
||||||
aria-selected="@(isSelected ? "true" : null)">
|
aria-selected="@(isSelected ? "true" : null)">
|
||||||
@@ -51,6 +57,15 @@ else
|
|||||||
{
|
{
|
||||||
<span class="tv-spacer"></span>
|
<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>
|
<span class="tv-content" @onclick="() => OnContentClick(key)" @onclick:stopPropagation>
|
||||||
@NodeContent(item)
|
@NodeContent(item)
|
||||||
</span>
|
</span>
|
||||||
@@ -99,6 +114,15 @@ else
|
|||||||
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
|
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
|
||||||
[Parameter] public string? StorageKey { get; set; }
|
[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()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
_items = Items;
|
_items = Items;
|
||||||
@@ -182,6 +206,32 @@ else
|
|||||||
|
|
||||||
StateHasChanged();
|
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)
|
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) {
|
load: function (storageKey) {
|
||||||
return sessionStorage.getItem("treeview:" + 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