| 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 { private FluentMenu? _menu; private IReadOnlyList<MenuButtonItem>? _renderedItems; private bool _refreshMenuAfterRender; [Parameter] public string? Anchor { get; set; } [Parameter] public bool Open { get; set; } [Parameter] public bool Anchored { get; set; } = true; [Parameter] public int? VerticalThreshold { get; set; } /// <summary> /// Raised when the <see cref="Open"/> property changed. /// </summary> [Parameter] public EventCallback<bool> OpenChanged { get; set; } [Parameter] public EventCallback OnRenderComplete { 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; } [Inject] public required IMenuService MenuService { get; init; } // Each menu item is approximately 32px tall, plus 16px padding for the menu container. private const int EstimatedItemHeight = 32; private const int MenuVerticalPadding = 16; private int CalculatedVerticalThreshold => VerticalThreshold ?? (Items.Count * EstimatedItemHeight + MenuVerticalPadding); protected override void OnParametersSet() { if (!ReferenceEquals(_renderedItems, Items)) { _renderedItems = Items; _refreshMenuAfterRender = Open; } } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender && OnRenderComplete.HasDelegate) { await OnRenderComplete.InvokeAsync(); } if (_refreshMenuAfterRender) { _refreshMenuAfterRender = false; if (_menu is { Id: { } menuId }) { await MenuService.RefreshMenuAsync(menuId, Open); } } } public async Task CloseAsync() { await SetOpenAsync(false); } public async Task OpenAsync(int screenWidth, int screenHeight, int clientX, int clientY) { if (_menu is { } menu) { // Calculate the position to display the context menu using the cursor position (clientX, clientY) // together with the screen width and height. // The menu may need to be displayed above or left of the cursor to fit in the screen. var left = 0; var right = 0; var top = 0; var bottom = 0; if (clientX + menu.HorizontalThreshold > screenWidth) { right = screenWidth - clientX; } else { left = clientX; } if (clientY + CalculatedVerticalThreshold > screenHeight) { bottom = screenHeight - clientY; } else { top = clientY; } // Overwrite the style. We don't want to add new position values each time the menu is opened. Style = new StyleBuilder() .AddStyle("left", $"{left}px", left != 0) .AddStyle("right", $"{right}px", right != 0) .AddStyle("top", $"{top}px", top != 0) .AddStyle("bottom", $"{bottom}px", bottom != 0) // Width values come from fluentui-blazor stylesheet; max-width uses an app CSS variable so nested submenus stay in sync. // Explicitly set to override min-width: fit-content applied by library to some menus. .AddStyle("max-width", "var(--aspire-menu-max-width)") .AddStyle("min-width", "64px") .Build(); await SetOpenAsync(true); StateHasChanged(); } } private async Task HandleItemClicked(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(); } if (OnSecondaryActionComplete.HasDelegate) { await OnSecondaryActionComplete.InvokeAsync(); } else { StateHasChanged(); if (_menu is { Id: not null } menu) { await MenuService.RefreshMenuAsync(menu.Id, isOpen: true); } } } private async Task OnOpenChanged(bool open) { await SetOpenAsync(open); } private async Task SetOpenAsync(bool open) { Open = open; StateHasChanged(); if (OpenChanged.HasDelegate) { await OpenChanged.InvokeAsync(open); } } }