feat(adminui): T0-1 reusable ContextMenu component + demo page

Adds a reusable right-click / context menu Blazor component for the AdminUI:
- ContextMenu.razor: right-click surface (browser menu suppressed) + optional
  ellipsis fallback trigger, both anchoring the menu at the pointer; keyboard
  nav (arrows/Enter/Esc), transparent backdrop outside-click close (no JS).
- ContextMenu.razor.css: scoped styles on the shared theme tokens.
- ContextMenuItem.cs: Label/OnClick/Icon/Disabled/IsSeparator model + Separator().
- Dev demo page at /dev/context-menu-demo.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 02:05:01 -04:00
parent f121f8ca16
commit 1d7afbb1eb
4 changed files with 324 additions and 0 deletions
@@ -0,0 +1,168 @@
@* Reusable right-click / context menu.
Consumers get two (independently optional) triggers over one item list:
• ChildContent — wrapped as a right-clickable surface (browser menu suppressed).
• ShowEllipsis — a "⋯" button, the keyboard/touch fallback for the right-click.
Both triggers open the same menu anchored at the pointer. Outside-click closes
via a transparent full-viewport backdrop (the standard Blazor dropdown pattern —
no JS alert/confirm). Keyboard: Arrow up/down move selection, Enter activates,
Esc closes. *@
@if (ChildContent is not null)
{
<span class="ctx-surface" @oncontextmenu="OpenAt" @oncontextmenu:preventDefault="true">
@ChildContent
</span>
}
@if (ShowEllipsis)
{
<button type="button" class="ctx-ellipsis" title="More actions"
aria-haspopup="true" aria-expanded="@_open" @onclick="OpenAt">
&#x22EF;@* ⋯ MIDLINE HORIZONTAL ELLIPSIS *@
</button>
}
@if (_open)
{
@* Transparent catch-all: any click that misses the menu closes it. *@
<div class="ctx-backdrop" @onclick="Close"></div>
<div class="ctx-menu" style="top:@(_y.ToString(Culture))px; left:@(_x.ToString(Culture))px"
role="menu" tabindex="0" @ref="_menuRef" @onkeydown="OnKeyDown">
@for (var i = 0; i < Items.Count; i++)
{
var item = Items[i];
if (item.IsSeparator)
{
<div class="ctx-separator" role="separator"></div>
}
else
{
var index = i;
<button type="button" role="menuitem"
class="ctx-item @(index == _activeIndex ? "active" : "")"
disabled="@item.Disabled"
@onclick="() => Activate(item)">
@if (!string.IsNullOrEmpty(item.Icon))
{
<span class="ctx-icon">@item.Icon</span>
}
<span class="ctx-label">@item.Label</span>
</button>
}
}
</div>
}
@code {
private static readonly System.Globalization.CultureInfo Culture =
System.Globalization.CultureInfo.InvariantCulture;
/// <summary>The rows to show. Separators are permitted.</summary>
[Parameter] public IReadOnlyList<ContextMenuItem> Items { get; set; } = Array.Empty<ContextMenuItem>();
/// <summary>Wrapped as the right-clickable surface. Optional.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Render the "⋯" trigger button (keyboard/touch fallback).</summary>
[Parameter] public bool ShowEllipsis { get; set; } = true;
private bool _open;
private double _x;
private double _y;
private int _activeIndex = -1;
private ElementReference _menuRef;
private bool _focusPending;
// Both triggers carry a MouseEventArgs, so the menu anchors at the pointer
// (right-click point, or the click point on the "⋯" button) with no JS.
private void OpenAt(MouseEventArgs e)
{
_x = e.ClientX;
_y = e.ClientY;
_activeIndex = FirstEnabledIndex();
_open = true;
_focusPending = true;
}
private void Close()
{
_open = false;
_activeIndex = -1;
}
private void Activate(ContextMenuItem item)
{
if (item.Disabled || item.IsSeparator) return;
Close();
item.OnClick?.Invoke();
}
private void OnKeyDown(KeyboardEventArgs e)
{
switch (e.Key)
{
case "Escape":
Close();
break;
case "ArrowDown":
Move(1);
break;
case "ArrowUp":
Move(-1);
break;
case "Enter":
case " ":
if (_activeIndex >= 0 && _activeIndex < Items.Count)
{
Activate(Items[_activeIndex]);
}
break;
}
}
// Advance the selection in the given direction, skipping separators and
// disabled rows, wrapping around the ends.
private void Move(int dir)
{
if (Items.Count == 0) return;
var i = _activeIndex;
for (var step = 0; step < Items.Count; step++)
{
i = (i + dir + Items.Count) % Items.Count;
var item = Items[i];
if (!item.IsSeparator && !item.Disabled)
{
_activeIndex = i;
return;
}
}
}
private int FirstEnabledIndex()
{
for (var i = 0; i < Items.Count; i++)
{
if (!Items[i].IsSeparator && !Items[i].Disabled) return i;
}
return -1;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_focusPending)
{
_focusPending = false;
try
{
await _menuRef.FocusAsync();
}
catch
{
// The menu may have closed before the focus round-trip completed.
}
}
}
}