@* Reusable hierarchical tree view with expand/collapse, ARIA roles, and guide lines *@
@typeparam TItem
@inject IJSRuntime JSRuntime
@if (_items is null || _items.Count == 0)
{
if (EmptyContent != null)
{
@EmptyContent
}
}
else
{
@foreach (var item in _items)
{
RenderNode(item, 0);
}
}
@if (_showContextMenu && _contextMenuItem != null && ContextMenu != null)
{
}
@{ void RenderNode(TItem item, int depth)
{
var key = KeySelector(item);
var children = ChildrenSelector(item);
var isBranch = HasChildrenSelector(item);
var isExpanded = _expandedKeys.Contains(KeyStr(key));
OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)">
@if (isBranch)
{
ToggleExpand(key)" @onclick:stopPropagation>@(isExpanded ? "\u2212" : "+")
}
else
{
}
OnContentClick(key)" @onclick:stopPropagation>
@NodeContent(item)
@if (isBranch && isExpanded && children is { Count: > 0 })
{
@foreach (var child in children)
{
RenderNode(child, depth + 1);
}
}
}
}
@code {
private IReadOnlyList? _items;
private HashSet _expandedKeys = new();
/// Normalize any key object to a string for consistent comparison with sessionStorage.
private string KeyStr(object key) => key.ToString()!;
private bool _initialExpansionApplied;
private bool _storageLoaded;
private TItem? _contextMenuItem;
private double _contextMenuX;
private double _contextMenuY;
private bool _showContextMenu;
[Parameter, EditorRequired] public IReadOnlyList Items { get; set; } = [];
[Parameter, EditorRequired] public Func> ChildrenSelector { get; set; } = default!;
[Parameter, EditorRequired] public Func HasChildrenSelector { get; set; } = default!;
[Parameter, EditorRequired] public Func KeySelector { get; set; } = default!;
[Parameter, EditorRequired] public RenderFragment NodeContent { get; set; } = default!;
[Parameter] public RenderFragment? EmptyContent { get; set; }
[Parameter] public RenderFragment? ContextMenu { get; set; }
[Parameter] public int IndentPx { get; set; } = 24;
[Parameter] public bool ShowGuideLines { get; set; } = true;
[Parameter] public Func? InitiallyExpanded { get; set; }
[Parameter] public bool Selectable { get; set; }
[Parameter] public object? SelectedKey { get; set; }
[Parameter] public EventCallback SelectedKeyChanged { get; set; }
[Parameter] public string SelectedCssClass { get; set; } = "bg-primary bg-opacity-10";
[Parameter] public string? StorageKey { get; set; }
protected override void OnParametersSet()
{
_items = Items;
if (!_initialExpansionApplied && InitiallyExpanded != null && _items is { Count: > 0 })
{
// Only apply InitiallyExpanded when there is no StorageKey, or storage
// has already been checked and returned nothing (no prior state).
if (StorageKey == null || (_storageLoaded && _expandedKeys.Count == 0))
{
_initialExpansionApplied = true;
ApplyInitialExpansion(_items);
}
}
// Clear selection if the selected key no longer exists in the current items tree
if (Selectable && SelectedKey != null && _items is not null && !KeyExistsInTree(_items, SelectedKey))
{
_ = SelectedKeyChanged.InvokeAsync(null);
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && StorageKey != null)
{
var json = await JSRuntime.InvokeAsync("treeviewStorage.load", StorageKey);
_storageLoaded = true;
if (json != null)
{
var keys = System.Text.Json.JsonSerializer.Deserialize>(json);
if (keys != null)
{
_expandedKeys = new HashSet(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();
}
}
private bool KeyExistsInTree(IReadOnlyList items, object key)
{
foreach (var item in items)
{
if (key.Equals(KeySelector(item)))
return true;
var children = ChildrenSelector(item);
if (children is { Count: > 0 } && KeyExistsInTree(children, key))
return true;
}
return false;
}
private void ApplyInitialExpansion(IReadOnlyList items)
{
foreach (var item in items)
{
if (InitiallyExpanded!(item))
{
_expandedKeys.Add(KeyStr(KeySelector(item)));
}
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
ApplyInitialExpansion(children);
}
}
}
private void ToggleExpand(object key)
{
var k = KeyStr(key);
if (!_expandedKeys.Remove(k))
{
_expandedKeys.Add(k);
}
PersistExpandedState();
}
private void PersistExpandedState()
{
if (StorageKey != null)
{
var json = System.Text.Json.JsonSerializer.Serialize(_expandedKeys.ToList());
_ = JSRuntime.InvokeVoidAsync("treeviewStorage.save", StorageKey, json);
}
}
private async Task OnContentClick(object key)
{
if (Selectable)
{
await SelectedKeyChanged.InvokeAsync(key);
}
}
private void OnContextMenu(MouseEventArgs e, TItem item)
{
if (ContextMenu == null) return;
_contextMenuItem = item;
_contextMenuX = e.ClientX;
_contextMenuY = e.ClientY;
_showContextMenu = true;
}
private void DismissContextMenu()
{
_showContextMenu = false;
_contextMenuItem = default;
}
/// Expand every branch node in the tree.
public void ExpandAll()
{
if (_items is { Count: > 0 })
{
ExpandAllRecursive(_items);
}
PersistExpandedState();
StateHasChanged();
}
private void ExpandAllRecursive(IReadOnlyList items)
{
foreach (var item in items)
{
if (HasChildrenSelector(item))
{
_expandedKeys.Add(KeyStr(KeySelector(item)));
}
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
ExpandAllRecursive(children);
}
}
}
/// Collapse every node in the tree.
public void CollapseAll()
{
_expandedKeys.Clear();
PersistExpandedState();
StateHasChanged();
}
///
/// Expand all ancestors of the given key so it becomes visible.
/// Optionally select the node.
///
public async Task RevealNode(object key, bool select = false)
{
var parentLookup = BuildParentLookup();
var k = KeyStr(key);
// If key is not in the tree at all, no-op
if (!parentLookup.ContainsKey(k))
return;
// Walk up through ancestors
var current = k;
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 BuildParentLookup()
{
var lookup = new Dictionary();
if (_items is { Count: > 0 })
{
BuildParentLookupRecursive(_items, null, lookup);
}
return lookup;
}
private void BuildParentLookupRecursive(IReadOnlyList items, string? parentKey, Dictionary lookup)
{
foreach (var item in items)
{
var key = KeyStr(KeySelector(item));
lookup[key] = parentKey;
var children = ChildrenSelector(item);
if (children is { Count: > 0 })
{
BuildParentLookupRecursive(children, key, lookup);
}
}
}
}