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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user