@* 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 { } @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));
  • @if (isBranch) { @(isExpanded ? "\u2212" : "+") } else { } @NodeContent(item)
    @if (isBranch && isExpanded && children is { Count: > 0 }) { }
  • } } @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); } } } }