feat(ui): add sessionStorage persistence for TreeView expansion state (R11)

This commit is contained in:
Joseph Doherty
2026-03-23 02:28:54 -04:00
parent da4f29f6ee
commit d3a6ed5f68
4 changed files with 125 additions and 3 deletions

View File

@@ -1,5 +1,6 @@
@* Reusable hierarchical tree view with expand/collapse, ARIA roles, and guide lines *@ @* Reusable hierarchical tree view with expand/collapse, ARIA roles, and guide lines *@
@typeparam TItem @typeparam TItem
@inject IJSRuntime JSRuntime
@if (_items is null || _items.Count == 0) @if (_items is null || _items.Count == 0)
{ {
@@ -58,6 +59,7 @@ else
private IReadOnlyList<TItem>? _items; private IReadOnlyList<TItem>? _items;
private HashSet<object> _expandedKeys = new(); private HashSet<object> _expandedKeys = new();
private bool _initialExpansionApplied; private bool _initialExpansionApplied;
private bool _storageLoaded;
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = []; [Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!; [Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
@@ -72,6 +74,7 @@ else
[Parameter] public object? SelectedKey { get; set; } [Parameter] public object? SelectedKey { get; set; }
[Parameter] public EventCallback<object?> SelectedKeyChanged { get; set; } [Parameter] public EventCallback<object?> SelectedKeyChanged { get; set; }
[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; }
protected override void OnParametersSet() protected override void OnParametersSet()
{ {
@@ -79,8 +82,40 @@ else
if (!_initialExpansionApplied && InitiallyExpanded != null && _items is { Count: > 0 }) if (!_initialExpansionApplied && InitiallyExpanded != null && _items is { Count: > 0 })
{ {
_initialExpansionApplied = true; // Only apply InitiallyExpanded when there is no StorageKey, or storage
ApplyInitialExpansion(_items); // has already been checked and returned nothing (no prior state).
if (StorageKey == null || (_storageLoaded && _expandedKeys.Count == 0))
{
_initialExpansionApplied = true;
ApplyInitialExpansion(_items);
}
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && StorageKey != null)
{
var json = await JSRuntime.InvokeAsync<string?>("treeviewStorage.load", StorageKey);
_storageLoaded = true;
if (json != null)
{
var keys = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
if (keys != null)
{
_expandedKeys = new HashSet<object>(keys);
_initialExpansionApplied = true;
}
}
else if (InitiallyExpanded != null && _items is { Count: > 0 } && !_initialExpansionApplied)
{
// Storage returned null (no prior state) — fall back to InitiallyExpanded
_initialExpansionApplied = true;
ApplyInitialExpansion(_items);
}
StateHasChanged();
} }
} }
@@ -107,6 +142,18 @@ else
{ {
_expandedKeys.Add(key); _expandedKeys.Add(key);
} }
PersistExpandedState();
}
private void PersistExpandedState()
{
if (StorageKey != null)
{
var keys = _expandedKeys.Select(k => k.ToString()!).ToList();
var json = System.Text.Json.JsonSerializer.Serialize(keys);
_ = JSRuntime.InvokeVoidAsync("treeviewStorage.save", StorageKey, json);
}
} }
private async Task OnContentClick(object key) private async Task OnContentClick(object key)

View File

@@ -0,0 +1,8 @@
window.treeviewStorage = {
save: function (storageKey, keysJson) {
sessionStorage.setItem("treeview:" + storageKey, keysJson);
},
load: function (storageKey) {
return sessionStorage.getItem("treeview:" + storageKey);
}
};

View File

@@ -92,6 +92,7 @@
} }
}); });
</script> </script>
<script src="/js/treeview-storage.js"></script>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>

View File

@@ -33,7 +33,8 @@ public class TreeViewTests : BunitContext
bool selectable = false, bool selectable = false,
object? selectedKey = null, object? selectedKey = null,
Action<object?>? onSelectedKeyChanged = null, Action<object?>? onSelectedKeyChanged = null,
string? selectedCssClass = null) string? selectedCssClass = null,
string? storageKey = null)
{ {
return Render<TreeView<TestNode>>(parameters => return Render<TreeView<TestNode>>(parameters =>
{ {
@@ -61,6 +62,11 @@ public class TreeViewTests : BunitContext
{ {
parameters.Add(p => p.SelectedCssClass, selectedCssClass); parameters.Add(p => p.SelectedCssClass, selectedCssClass);
} }
if (storageKey != null)
{
parameters.Add(p => p.StorageKey, storageKey);
}
}); });
} }
@@ -317,4 +323,64 @@ public class TreeViewTests : BunitContext
var alphaLi = cut.FindAll("li[role='treeitem']")[0]; var alphaLi = cut.FindAll("li[role='treeitem']")[0];
Assert.Equal("true", alphaLi.GetAttribute("aria-selected")); Assert.Equal("true", alphaLi.GetAttribute("aria-selected"));
} }
[Fact]
public void SessionStorage_NullKey_NoJsInteropCalls()
{
var cut = RenderTreeView();
// Expand Alpha
cut.Find(".tv-toggle").Click();
// No JS interop calls should have been made
Assert.Empty(JSInterop.Invocations);
}
[Fact]
public void SessionStorage_Set_ExpandWritesToStorage()
{
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = RenderTreeView(storageKey: "test-tree");
// Expand Alpha
cut.Find(".tv-toggle").Click();
// Verify save was called
var saveInvocations = JSInterop.Invocations
.Where(i => i.Identifier == "treeviewStorage.save")
.ToList();
Assert.Single(saveInvocations);
}
[Fact]
public void SessionStorage_RestoresExpandedOnMount()
{
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[\"a\"]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = RenderTreeView(storageKey: "test-tree");
// Alpha's children should be visible because "a" was restored from storage
var labels = cut.FindAll(".node-label");
Assert.Contains(labels, l => l.TextContent == "Alpha-1");
Assert.Contains(labels, l => l.TextContent == "Alpha-2");
}
[Fact]
public void SessionStorage_TakesPrecedenceOverInitiallyExpanded()
{
// Storage returns empty array — meaning user explicitly collapsed everything
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[]");
JSInterop.SetupVoid("treeviewStorage.save", _ => true);
var cut = RenderTreeView(
storageKey: "test-tree",
initiallyExpanded: n => n.Key == "a");
// Alpha should NOT be expanded — storage (empty) wins over InitiallyExpanded
var labels = cut.FindAll(".node-label");
Assert.DoesNotContain(labels, l => l.TextContent == "Alpha-1");
}
} }