merge wt/t0-1 into v3/batch2-raw-ui (Track 0)

This commit is contained in:
Joseph Doherty
2026-07-16 02:08:20 -04:00
4 changed files with 324 additions and 0 deletions
@@ -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">
&#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.
}
}
}
}
@@ -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 };
}