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);
        }
    }
}