feat(ui): add right-click context menu to TreeView (R15)

This commit is contained in:
Joseph Doherty
2026-03-23 02:32:57 -04:00
parent f127efe6ea
commit 4e5b5facec
2 changed files with 98 additions and 2 deletions

View File

@@ -19,6 +19,14 @@ else
</ul>
}
@if (_showContextMenu && _contextMenuItem != null && ContextMenu != null)
{
<div class="tv-ctx-overlay" @onclick="DismissContextMenu" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:1049;background:transparent;"></div>
<div class="dropdown-menu show" style="position:fixed;top:@(_contextMenuY)px;left:@(_contextMenuX)px;z-index:1050;">
@ContextMenu(_contextMenuItem)
</div>
}
@{ void RenderNode(TItem item, int depth)
{
var key = KeySelector(item);
@@ -29,7 +37,8 @@ else
<li role="treeitem" @key="key"
aria-expanded="@(isBranch ? (isExpanded ? "true" : "false") : null)"
aria-selected="@(Selectable && SelectedKey != null && SelectedKey.Equals(key) ? "true" : null)">
<div class="tv-row @(Selectable && SelectedKey != null && SelectedKey.Equals(key) ? SelectedCssClass : "")" style="padding-left: @(depth * IndentPx)px">
<div class="tv-row @(Selectable && SelectedKey != null && SelectedKey.Equals(key) ? SelectedCssClass : "")" style="padding-left: @(depth * IndentPx)px"
@oncontextmenu="(e) => OnContextMenu(e, item)" @oncontextmenu:preventDefault="@(ContextMenu != null)">
@if (isBranch)
{
<span class="tv-toggle" @onclick="() => ToggleExpand(key)" @onclick:stopPropagation>@(isExpanded ? "\u2212" : "+")</span>
@@ -60,6 +69,10 @@ else
private HashSet<object> _expandedKeys = new();
private bool _initialExpansionApplied;
private bool _storageLoaded;
private TItem? _contextMenuItem;
private double _contextMenuX;
private double _contextMenuY;
private bool _showContextMenu;
[Parameter, EditorRequired] public IReadOnlyList<TItem> Items { get; set; } = [];
[Parameter, EditorRequired] public Func<TItem, IReadOnlyList<TItem>> ChildrenSelector { get; set; } = default!;
@@ -67,6 +80,7 @@ else
[Parameter, EditorRequired] public Func<TItem, object> KeySelector { get; set; } = default!;
[Parameter, EditorRequired] public RenderFragment<TItem> NodeContent { get; set; } = default!;
[Parameter] public RenderFragment? EmptyContent { get; set; }
[Parameter] public RenderFragment<TItem>? ContextMenu { get; set; }
[Parameter] public int IndentPx { get; set; } = 24;
[Parameter] public bool ShowGuideLines { get; set; } = true;
[Parameter] public Func<TItem, bool>? InitiallyExpanded { get; set; }
@@ -164,6 +178,22 @@ else
}
}
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;
}
/// <summary>Expand every branch node in the tree.</summary>
public void ExpandAll()
{

View File

@@ -1,5 +1,6 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using ScadaLink.CentralUI.Components.Shared;
namespace ScadaLink.CentralUI.Tests;
@@ -34,7 +35,8 @@ public class TreeViewTests : BunitContext
object? selectedKey = null,
Action<object?>? onSelectedKeyChanged = null,
string? selectedCssClass = null,
string? storageKey = null)
string? storageKey = null,
RenderFragment<TestNode>? contextMenu = null)
{
return Render<TreeView<TestNode>>(parameters =>
{
@@ -67,6 +69,11 @@ public class TreeViewTests : BunitContext
{
parameters.Add(p => p.StorageKey, storageKey);
}
if (contextMenu != null)
{
parameters.Add(p => p.ContextMenu, contextMenu);
}
});
}
@@ -456,4 +463,63 @@ public class TreeViewTests : BunitContext
var labels = cut.FindAll(".node-label");
Assert.Equal(2, labels.Count);
}
[Fact]
public void ContextMenu_Null_NoMenuRendered()
{
var cut = RenderTreeView();
// Right-click Alpha
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
// No dropdown-menu should appear
Assert.Throws<Bunit.ElementNotFoundException>(() => cut.Find(".dropdown-menu"));
}
[Fact]
public void ContextMenu_RightClickShowsMenu()
{
var cut = RenderTreeView(contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
// Right-click Alpha
var row = cut.Find(".tv-row");
row.TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
// Dropdown menu should contain the button for Alpha
var menu = cut.Find(".dropdown-menu");
Assert.NotNull(menu);
var btn = menu.QuerySelector(".ctx-btn");
Assert.NotNull(btn);
Assert.Equal("Alpha", btn!.TextContent);
}
[Fact]
public void ContextMenu_RightClickDifferentNode_ReplacesMenu()
{
var cut = RenderTreeView(
initiallyExpanded: n => n.Key == "a",
contextMenu: node => builder =>
{
builder.AddMarkupContent(0, $"<button class=\"ctx-btn\">{node.Label}</button>");
});
// Right-click Alpha
var rows = cut.FindAll(".tv-row");
rows[0].TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 100, ClientY = 200 });
// Now right-click Alpha-1
rows = cut.FindAll(".tv-row");
rows[1].TriggerEvent("oncontextmenu", new MouseEventArgs { ClientX = 150, ClientY = 250 });
// Should be only one dropdown-menu, showing Alpha-1
var menus = cut.FindAll(".dropdown-menu");
Assert.Single(menus);
var btn = menus[0].QuerySelector(".ctx-btn");
Assert.NotNull(btn);
Assert.Equal("Alpha-1", btn!.TextContent);
}
}