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? _appliedOpen;
    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.
                    await _menu.OpenMenuAsync();
                }
                else
                {
                    await _menu.CloseMenuAsync();
                }
 
                _appliedOpen = Open;
            }
        }
    }
 
    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 can close the browser popover without raising OpenedChanged.
            // Treat every cursor request as a new open/position request even when Open is still true.
            _refreshMenuAfterRender = true;
            await SetOpenAsync(true);
 
            StateHasChanged();
        }
    }
 
    private Task HandleItemClicked(MenuButtonItem item)
    {
        return item.Role is MenuItemRole.Checkbox or MenuItemRole.Radio
            ? Task.CompletedTask
            : HandleItemActivatedAsync(item);
    }
 
    private Task HandleItemCheckedChanged(MenuButtonItem item, bool? isChecked)
    {
        return isChecked is true && item.Role is MenuItemRole.Checkbox or MenuItemRole.Radio
            ? HandleItemActivatedAsync(item)
            : Task.CompletedTask;
    }
 
    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();
        }
 
        if (OnSecondaryActionComplete.HasDelegate)
        {
            await OnSecondaryActionComplete.InvokeAsync();
        }
        else
        {
            StateHasChanged();
        }
    }
 
    private async Task OnOpenChanged(bool open)
    {
        _appliedOpen = open;
        await SetOpenAsync(open);
    }
 
    private async Task SetOpenAsync(bool open)
    {
        Open = open;
        StateHasChanged();
 
        if (OpenChanged.HasDelegate)
        {
            await OpenChanged.InvokeAsync(open);
        }
    }
}