feat(ui): add ExpandAll, CollapseAll, RevealNode to TreeView (R12, R13)

This commit is contained in:
Joseph Doherty
2026-03-23 02:30:53 -04:00
parent d3a6ed5f68
commit f127efe6ea
2 changed files with 164 additions and 0 deletions

View File

@@ -163,4 +163,95 @@ else
await SelectedKeyChanged.InvokeAsync(key);
}
}
/// <summary>Expand every branch node in the tree.</summary>
public void ExpandAll()
{
if (_items is { Count: > 0 })
{
ExpandAllRecursive(_items);
}
PersistExpandedState();
StateHasChanged();
}
private void ExpandAllRecursive(IReadOnlyList<TItem> items)
{
foreach (var item in items)
{
if (HasChildrenSelector(item))
{
_expandedKeys.Add(KeySelector(item));
}
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
ExpandAllRecursive(children);
}
}
}
/// <summary>Collapse every node in the tree.</summary>
public void CollapseAll()
{
_expandedKeys.Clear();
PersistExpandedState();
StateHasChanged();
}
/// <summary>
/// Expand all ancestors of the given key so it becomes visible.
/// Optionally select the node.
/// </summary>
public async Task RevealNode(object key, bool select = false)
{
var parentLookup = BuildParentLookup();
// If key is not in the tree at all, no-op
if (!parentLookup.ContainsKey(key))
return;
// Walk up through ancestors
var current = key;
while (parentLookup.TryGetValue(current, out var parentKey) && parentKey != null)
{
_expandedKeys.Add(parentKey);
current = parentKey;
}
if (select && Selectable)
{
await SelectedKeyChanged.InvokeAsync(key);
}
PersistExpandedState();
StateHasChanged();
}
private Dictionary<object, object?> BuildParentLookup()
{
var lookup = new Dictionary<object, object?>();
if (_items is { Count: > 0 })
{
BuildParentLookupRecursive(_items, null, lookup);
}
return lookup;
}
private void BuildParentLookupRecursive(IReadOnlyList<TItem> items, object? parentKey, Dictionary<object, object?> lookup)
{
foreach (var item in items)
{
var key = KeySelector(item);
lookup[key] = parentKey;
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
BuildParentLookupRecursive(children, key, lookup);
}
}
}
}

View File

@@ -383,4 +383,77 @@ public class TreeViewTests : BunitContext
var labels = cut.FindAll(".node-label");
Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1");
}
[Fact]
public void ExpandAll_ExpandsAllBranches()
{
var cut = RenderTreeView();
// Everything collapsed initially
Assert.Equal(2, cut.FindAll(".node-label").Count);
cut.InvokeAsync(() => cut.Instance.ExpandAll());
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-1");
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
}
[Fact]
public void CollapseAll_CollapsesAllBranches()
{
var cut = RenderTreeView(initiallyExpanded: _ => true);
// Verify deep content is visible
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
cut.InvokeAsync(() => cut.Instance.CollapseAll());
// Only roots should be visible
labels = cut.FindAll(".node-label");
Assert.Equal(2, labels.Count);
Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1");
}
[Fact]
public void RevealNode_ExpandsAncestors()
{
var cut = RenderTreeView();
// Everything collapsed initially
Assert.Equal(2, cut.FindAll(".node-label").Count);
cut.InvokeAsync(() => cut.Instance.RevealNode("a2x"));
// Alpha-2-X should now be visible (Alpha and Alpha-2 expanded)
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-2-X");
Assert.Contains(labels, l => l.TextContent == "Alpha-1"); // sibling also visible since Alpha is expanded
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
}
[Fact]
public void RevealNode_WithSelect_SelectsNode()
{
object? selected = null;
var cut = RenderTreeView(selectable: true, onSelectedKeyChanged: k => selected = k);
cut.InvokeAsync(() => cut.Instance.RevealNode("a2x", select: true));
Assert.Equal("a2x", selected);
}
[Fact]
public void RevealNode_UnknownKey_NoOp()
{
var cut = RenderTreeView();
cut.InvokeAsync(() => cut.Instance.RevealNode("nonexistent"));
// Alpha should still be collapsed
var labels = cut.FindAll(".node-label");
Assert.Equal(2, labels.Count);
}
}