c3277b52c9
Wave-0 reviewer findings (no Critical/High): - T0-1 M: ContextMenuItem.OnClick Action→Func<Task> (kills async-void at menu call sites Wave A/B/C need); keyboard-activated ⋯ now anchors below the button instead of viewport (0,0); focus returns to trigger on close; backdrop closes on native right-click too. - T0-3 M: guard test now discovers *DriverFactoryExtensions.Register by convention from the deployed driver assemblies instead of a hand-copied list, so a future driver lacking a constant is actually caught. - T0-2 L: corrected the round-trip 'sole non-round-trippable shape' comment (any table whose final row is a lone empty field). Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
195 lines
6.7 KiB
Plaintext
195 lines
6.7 KiB
Plaintext
@* 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.
|
|
|
|
Mouse/touch open the menu anchored at the pointer (fixed positioning). Keyboard
|
|
activation of the "⋯" button carries no pointer coordinates, so the menu instead
|
|
anchors just below the button (absolute positioning inside the trigger wrap).
|
|
Outside-click (or a right-click elsewhere) closes via a transparent full-viewport
|
|
backdrop — no JS alert/confirm. Keyboard: Arrow up/down move selection, Enter/Space
|
|
activate, Esc closes; focus returns to the "⋯" trigger on close. *@
|
|
|
|
@if (ChildContent is not null)
|
|
{
|
|
<span class="ctx-surface" @oncontextmenu="OpenAt" @oncontextmenu:preventDefault="true">
|
|
@ChildContent
|
|
</span>
|
|
}
|
|
|
|
@if (_open)
|
|
{
|
|
@* Transparent catch-all: any click — or a right-click — that misses the menu closes it. *@
|
|
<div class="ctx-backdrop" @onclick="Close" @oncontextmenu="Close" @oncontextmenu:preventDefault="true"></div>
|
|
}
|
|
|
|
@if (ShowEllipsis)
|
|
{
|
|
<span class="ctx-ellipsis-wrap">
|
|
<button type="button" class="ctx-ellipsis" title="More actions"
|
|
aria-haspopup="true" aria-expanded="@_open" @ref="_ellipsisRef" @onclick="OpenAt">
|
|
⋯@* ⋯ MIDLINE HORIZONTAL ELLIPSIS *@
|
|
</button>
|
|
@if (_open && _anchorToEllipsis)
|
|
{
|
|
@RenderMenu("ctx-menu ctx-menu--anchored", null)
|
|
}
|
|
</span>
|
|
}
|
|
|
|
@if (_open && !_anchorToEllipsis)
|
|
{
|
|
@RenderMenu("ctx-menu", $"top:{_y.ToString(Culture)}px; left:{_x.ToString(Culture)}px")
|
|
}
|
|
|
|
@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 bool _anchorToEllipsis;
|
|
private int _activeIndex = -1;
|
|
private ElementReference _menuRef;
|
|
private ElementReference _ellipsisRef;
|
|
private bool _focusMenuPending;
|
|
private bool _focusEllipsisPending;
|
|
|
|
// Mouse right-click / touch-tap carry real pointer coordinates → anchor the menu there
|
|
// (fixed). Keyboard activation of the "⋯" button dispatches a click with clientX/Y == 0
|
|
// (no pointer) → anchor the menu below the button instead of at viewport origin (0,0).
|
|
private void OpenAt(MouseEventArgs e)
|
|
{
|
|
_anchorToEllipsis = e.ClientX == 0 && e.ClientY == 0;
|
|
_x = e.ClientX;
|
|
_y = e.ClientY;
|
|
_activeIndex = FirstEnabledIndex();
|
|
_open = true;
|
|
_focusMenuPending = true;
|
|
}
|
|
|
|
private void Close()
|
|
{
|
|
if (!_open) return;
|
|
var returnFocus = _anchorToEllipsis; // keyboard-opened via the ⋯ button — restore its focus
|
|
_open = false;
|
|
_activeIndex = -1;
|
|
_anchorToEllipsis = false;
|
|
if (returnFocus && ShowEllipsis) _focusEllipsisPending = true;
|
|
}
|
|
|
|
private async Task Activate(ContextMenuItem item)
|
|
{
|
|
if (item.Disabled || item.IsSeparator) return;
|
|
Close();
|
|
if (item.OnClick is not null) await item.OnClick.Invoke();
|
|
}
|
|
|
|
private async Task 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)
|
|
{
|
|
await 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;
|
|
}
|
|
|
|
// The menu body, shared between the pointer-anchored (fixed) and ellipsis-anchored
|
|
// (absolute) placements so the item loop lives in exactly one place.
|
|
private RenderFragment RenderMenu(string cssClass, string? style) => __builder =>
|
|
{
|
|
<div class="@cssClass" style="@style" 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>
|
|
};
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
if (_focusMenuPending)
|
|
{
|
|
_focusMenuPending = false;
|
|
try { await _menuRef.FocusAsync(); }
|
|
catch { /* the menu may have closed before the focus round-trip completed */ }
|
|
}
|
|
if (_focusEllipsisPending)
|
|
{
|
|
_focusEllipsisPending = false;
|
|
try { await _ellipsisRef.FocusAsync(); }
|
|
catch { /* the trigger may have unmounted */ }
|
|
}
|
|
}
|
|
}
|