| File: Components\Controls\AspireMenu.razor.cs | Web Access |
| Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard) |
// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard.Model; using Microsoft.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components; using Microsoft.FluentUI.AspNetCore.Components.Utilities; using Microsoft.JSInterop; namespace Aspire.Dashboard.Components; public partial class AspireMenu : FluentComponentBase { public AspireMenu(LibraryConfiguration configuration) : base(configuration) { } private FluentMenu? _menu; private IReadOnlyList<MenuButtonItem>? _renderedItems; private bool _refreshMenuAfterRender; private bool _reopenInProgress; private bool? _appliedOpen; private string? _pendingSecondaryActionFocusId; private int _cursorLeft; private int _cursorTop; private string? HeaderId => Items.FirstOrDefault(item => item.IsHeader)?.Id; [Parameter] public string? Anchor { get; set; } [Parameter] public bool Open { get; set; } [Parameter] public bool Anchored { get; set; } = true; /// <summary> /// Raised when the <see cref="Open"/> property changed. /// </summary> [Parameter] public EventCallback<bool> OpenChanged { get; set; } /// <summary> /// Raised after a menu item's secondary action completes so the owner can regenerate the menu items. /// </summary> [Parameter] public EventCallback OnSecondaryActionComplete { get; set; } [Parameter] public required IReadOnlyList<MenuButtonItem> Items { get; set; } /// <summary> /// Gets or sets a value indicating whether focus should return to <see cref="Anchor"/> after a menu item is clicked. /// </summary> /// <remarks> /// Use this only for button-anchored menus where <see cref="Anchor"/> identifies the element that opened the menu. /// Do not enable it for cursor-positioned or context menus where <see cref="Anchor"/> is only used for positioning. /// </remarks> [Parameter] public bool RestoreFocusOnItemClick { get; set; } [Inject] public required IJSRuntime JS { get; init; } private string? CursorAnchorStyle => new StyleBuilder() .AddStyle("position", "fixed") .AddStyle("left", $"{_cursorLeft}px") .AddStyle("top", $"{_cursorTop}px") .AddStyle("width", "0") .AddStyle("height", "0") .AddStyle("anchor-name", $"--anchor-{Anchor}") .AddStyle("pointer-events", "none") .Build(); protected override void OnParametersSet() { if (!ReferenceEquals(_renderedItems, Items)) { _renderedItems = Items; _refreshMenuAfterRender = Open; } if (_appliedOpen != Open) { _refreshMenuAfterRender = true; } } protected override async Task OnAfterRenderAsync(bool firstRender) { if (_refreshMenuAfterRender) { _refreshMenuAfterRender = false; if (_menu is not null) { if (Open) { // Trigger identifies either the button anchor or the cursor anchor. The parameterless // path leaves placement to Fluent's CSS anchor positioning and viewport fallbacks. if (_reopenInProgress) { await _menu.CloseMenuAsync(); } await _menu.OpenMenuAsync(); } else { _reopenInProgress = false; await _menu.CloseMenuAsync(); } _appliedOpen = Open; } } if (_pendingSecondaryActionFocusId is { } focusId) { _pendingSecondaryActionFocusId = null; if (Open) { // Reopening the menu can focus its first item, so restore the action after it opens. await JS.InvokeVoidAsync("focusElement", focusId); } } } public async Task CloseAsync() { await SetOpenAsync(false); } public async Task OpenAsync(int clientX, int clientY) { if (_menu is not null) { _cursorLeft = clientX; _cursorTop = clientY; Style = new StyleBuilder() .AddStyle("max-width", "368px") .AddStyle("min-width", "64px") .Build(); // Escape and light-dismiss update the browser popover before their asynchronous // OpenedChanged notification reaches this component. Treat every cursor request as a // new open/position request even when Open is still true. _reopenInProgress = Open; _refreshMenuAfterRender = true; await SetOpenAsync(true); StateHasChanged(); } } private async Task HandleItemActivatedAsync(MenuButtonItem item) { await SetOpenAsync(false); if (RestoreFocusOnItemClick && !string.IsNullOrEmpty(Anchor)) { await JS.InvokeVoidAsync("focusElement", Anchor); } // Item callbacks can move focus to a dialog or another control, so restore the // menu trigger first to avoid stealing focus back after the callback completes. if (item.OnClick is { } onClick) { await onClick(); } } private async Task HandleSecondaryActionClicked(MenuButtonItem item) { if (item.OnSecondaryActionClick is { } onSecondaryActionClick) { await onSecondaryActionClick(); } _pendingSecondaryActionFocusId = $"{item.Id}-secondary-action"; if (OnSecondaryActionComplete.HasDelegate) { await OnSecondaryActionComplete.InvokeAsync(); } } private async Task OnOpenChanged(bool open) { // Fluent reports native popover toggles through asynchronous JS callbacks. During a deliberate // close-and-reopen, ignore the reset's close notification until the following open is confirmed. if (_reopenInProgress) { if (!open) { return; } _reopenInProgress = false; } _appliedOpen = open; await SetOpenAsync(open); } private async Task SetOpenAsync(bool open) { Open = open; StateHasChanged(); if (OpenChanged.HasDelegate) { await OpenChanged.InvokeAsync(open); } } }