@* 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) { @ChildContent } @if (_open) { @* Transparent catch-all: any click — or a right-click — that misses the menu closes it. *@
} @if (ShowEllipsis) { @if (_open && _anchorToEllipsis) { @RenderMenu("ctx-menu ctx-menu--anchored", null) } } @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; /// 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 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 => { }; 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 */ } } } }