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:
@@ -0,0 +1,35 @@
|
||||
@page "/dev/context-menu-demo"
|
||||
@* Dev-only host page to live-verify the reusable ContextMenu component. *@
|
||||
@attribute [Authorize(Policy = AdminUiPolicies.AuthenticatedRead)]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>ContextMenu demo</PageTitle>
|
||||
|
||||
<h1>ContextMenu demo</h1>
|
||||
<p>Right-click the card below, or use the <strong>⋯</strong> button.</p>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:1rem; margin:1rem 0;">
|
||||
<ContextMenu Items="_items" ShowEllipsis="false">
|
||||
<div style="padding:2rem 3rem; border:1px dashed var(--bs-border-color); border-radius:.5rem; user-select:none;">
|
||||
Right-click me
|
||||
</div>
|
||||
</ContextMenu>
|
||||
|
||||
<ContextMenu Items="_items" ShowEllipsis="true" />
|
||||
</div>
|
||||
|
||||
<p>Last action: <strong>@_lastAction</strong></p>
|
||||
|
||||
@code {
|
||||
private string _lastAction = "(none)";
|
||||
|
||||
private IReadOnlyList<ContextMenuItem> _items => new List<ContextMenuItem>
|
||||
{
|
||||
new() { Label = "Edit", Icon = "✏️", OnClick = () => Set("Edit") },
|
||||
new() { Label = "Duplicate", Icon = "📄", OnClick = () => Set("Duplicate") },
|
||||
ContextMenuItem.Separator(),
|
||||
new() { Label = "Delete", Icon = "🗑️", Disabled = true, OnClick = () => Set("Delete") },
|
||||
};
|
||||
|
||||
private void Set(string action) => _lastAction = action;
|
||||
}
|
||||
@@ -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">
|
||||
⋯@* ⋯ 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* Scoped styles for ContextMenu. Colours come from the shared ZB.MOM.WW.Theme
|
||||
tokens (theme.css) so the menu tracks the app palette in light and dark. */
|
||||
|
||||
.ctx-surface {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* The "⋯" fallback trigger — a compact, quiet icon button. */
|
||||
.ctx-ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ctx-ellipsis:hover,
|
||||
.ctx-ellipsis[aria-expanded="true"] {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--bs-border-color);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Transparent full-viewport catch-all; sits just under the menu. */
|
||||
.ctx-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1080;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 1081; /* above the backdrop, below toasts (1090) */
|
||||
min-width: 11rem;
|
||||
max-width: 20rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.16);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 0;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ctx-item:hover:not(:disabled),
|
||||
.ctx-item.active:not(:disabled) {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.ctx-item:disabled {
|
||||
color: var(--ink-faint);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ctx-icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
width: 1.1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ctx-label {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.ctx-separator {
|
||||
height: 1px;
|
||||
margin: 0.25rem 0.3rem;
|
||||
background: var(--bs-border-color);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// One entry in a <see cref="ContextMenu"/>. An item is either an actionable
|
||||
/// row (<see cref="Label"/> + <see cref="OnClick"/>) or a visual divider
|
||||
/// (<see cref="IsSeparator"/> = true, in which case the other members are
|
||||
/// ignored). Use the <see cref="Separator"/> factory for dividers.
|
||||
/// </summary>
|
||||
public sealed class ContextMenuItem
|
||||
{
|
||||
/// <summary>Text shown for the row.</summary>
|
||||
public string Label { get; init; } = "";
|
||||
|
||||
/// <summary>Invoked when the row is activated (click / Enter).</summary>
|
||||
public Action? OnClick { get; init; }
|
||||
|
||||
/// <summary>Optional leading icon — an emoji or single glyph.</summary>
|
||||
public string? Icon { get; init; }
|
||||
|
||||
/// <summary>Renders the row greyed-out and non-interactive.</summary>
|
||||
public bool Disabled { get; init; }
|
||||
|
||||
/// <summary>When true this entry is a horizontal divider, not a row.</summary>
|
||||
public bool IsSeparator { get; init; }
|
||||
|
||||
/// <summary>A horizontal divider between groups of items.</summary>
|
||||
public static ContextMenuItem Separator() => new() { IsSeparator = true };
|
||||
}
|
||||
Reference in New Issue
Block a user