review(track0): async ContextMenu OnClick + keyboard-⋯ anchoring + focus-return; discovery-driven DriverTypeNames guard; CSV round-trip comment

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
This commit is contained in:
Joseph Doherty
2026-07-16 02:17:37 -04:00
parent 9a896efecd
commit c3277b52c9
6 changed files with 169 additions and 81 deletions
@@ -4,10 +4,12 @@
• 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. *@
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)
{
@@ -16,44 +18,29 @@
</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>
@* 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>
}
<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++)
@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">
&#x22EF;@* ⋯ MIDLINE HORIZONTAL ELLIPSIS *@
</button>
@if (_open && _anchorToEllipsis)
{
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>
}
@RenderMenu("ctx-menu ctx-menu--anchored", null)
}
</div>
</span>
}
@if (_open && !_anchorToEllipsis)
{
@RenderMenu("ctx-menu", $"top:{_y.ToString(Culture)}px; left:{_x.ToString(Culture)}px")
}
@code {
@@ -72,35 +59,44 @@
private bool _open;
private double _x;
private double _y;
private bool _anchorToEllipsis;
private int _activeIndex = -1;
private ElementReference _menuRef;
private bool _focusPending;
private ElementReference _ellipsisRef;
private bool _focusMenuPending;
private bool _focusEllipsisPending;
// 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.
// 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;
_focusPending = 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 void Activate(ContextMenuItem item)
private async Task Activate(ContextMenuItem item)
{
if (item.Disabled || item.IsSeparator) return;
Close();
item.OnClick?.Invoke();
if (item.OnClick is not null) await item.OnClick.Invoke();
}
private void OnKeyDown(KeyboardEventArgs e)
private async Task OnKeyDown(KeyboardEventArgs e)
{
switch (e.Key)
{
@@ -117,7 +113,7 @@
case " ":
if (_activeIndex >= 0 && _activeIndex < Items.Count)
{
Activate(Items[_activeIndex]);
await Activate(Items[_activeIndex]);
}
break;
}
@@ -150,19 +146,49 @@
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 (_focusPending)
if (_focusMenuPending)
{
_focusPending = false;
try
{
await _menuRef.FocusAsync();
}
catch
{
// The menu may have closed before the focus round-trip completed.
}
_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 */ }
}
}
}
@@ -5,6 +5,12 @@
display: inline;
}
/* Positioned wrap so a keyboard-opened menu can anchor below the "⋯" button. */
.ctx-ellipsis-wrap {
position: relative;
display: inline-block;
}
/* The "⋯" fallback trigger — a compact, quiet icon button. */
.ctx-ellipsis {
display: inline-flex;
@@ -50,6 +56,14 @@
outline: none;
}
/* Keyboard-opened placement: anchored just below the "⋯" trigger instead of the pointer. */
.ctx-menu--anchored {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.25rem;
}
.ctx-item {
display: flex;
align-items: center;
@@ -11,8 +11,13 @@ 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>
/// Invoked when the row is activated (click / Enter). Async so per-node menu
/// actions (delete, redeploy, rename) can await their service calls without an
/// exception-swallowing <c>async void</c>; the menu awaits this and re-renders.
/// A synchronous handler returns <see cref="Task.CompletedTask"/>.
/// </summary>
public Func<Task>? OnClick { get; init; }
/// <summary>Optional leading icon — an emoji or single glyph.</summary>
public string? Icon { get; init; }