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;
}
}
};