From 1d7afbb1eb1fecc0eb18b641a3f3d6beae18b86e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 16 Jul 2026 02:05:01 -0400 Subject: [PATCH] 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 --- .../Pages/Dev/ContextMenuDemo.razor | 35 ++++ .../Components/Shared/ContextMenu.razor | 168 ++++++++++++++++++ .../Components/Shared/ContextMenu.razor.css | 93 ++++++++++ .../Components/Shared/ContextMenuItem.cs | 28 +++ 4 files changed, 324 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor new file mode 100644 index 00000000..db5cd1da --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Dev/ContextMenuDemo.razor @@ -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 + +ContextMenu demo + +

ContextMenu demo

+

Right-click the card below, or use the button.

+ +
+ +
+ Right-click me +
+
+ + +
+ +

Last action: @_lastAction

+ +@code { + private string _lastAction = "(none)"; + + private IReadOnlyList _items => new List + { + 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; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor new file mode 100644 index 00000000..ea8b00fc --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor @@ -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) +{ + + @ChildContent + +} + +@if (ShowEllipsis) +{ + +} + +@if (_open) +{ + @* Transparent catch-all: any click that misses the menu closes it. *@ +
+ + +} + +@code { + private static readonly System.Globalization.CultureInfo Culture = + System.Globalization.CultureInfo.InvariantCulture; + + /// The rows to show. Separators are permitted. + [Parameter] public IReadOnlyList Items { get; set; } = Array.Empty(); + + /// Wrapped as the right-clickable surface. Optional. + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// Render the "⋯" trigger button (keyboard/touch fallback). + [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. + } + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css new file mode 100644 index 00000000..df025623 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenu.razor.css @@ -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); +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs new file mode 100644 index 00000000..a513f7f0 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/ContextMenuItem.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared; + +/// +/// One entry in a . An item is either an actionable +/// row ( + ) or a visual divider +/// ( = true, in which case the other members are +/// ignored). Use the factory for dividers. +/// +public sealed class ContextMenuItem +{ + /// Text shown for the row. + public string Label { get; init; } = ""; + + /// Invoked when the row is activated (click / Enter). + public Action? OnClick { get; init; } + + /// Optional leading icon — an emoji or single glyph. + public string? Icon { get; init; } + + /// Renders the row greyed-out and non-interactive. + public bool Disabled { get; init; } + + /// When true this entry is a horizontal divider, not a row. + public bool IsSeparator { get; init; } + + /// A horizontal divider between groups of items. + public static ContextMenuItem Separator() => new() { IsSeparator = true }; +}