File: Components\Controls\AspireMenuButton.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 Aspire.Dashboard.Utils;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;
using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons;
 
namespace Aspire.Dashboard.Components;
 
public partial class AspireMenuButton : FluentComponentBase, IAsyncDisposable
{
    private static readonly Icon s_defaultIcon = new Icons.Regular.Size24.ChevronDown();
    private const int InitializationWaitMilliseconds = 100;
 
    private IJSObjectReference? _jsModule;
    private bool _renderMenu;
    private bool _menuRenderComplete;
    private bool _openWhenMenuRenderCompletes;
    private bool _visible;
    private Icon? _icon;
    private MenuButtonItem[] _items = [];
    private bool _disabled;
    private bool _hasActionableItems = true;
    private Func<IList<MenuButtonItem>>? _renderedItemsProvider;
 
    [Parameter]
    public string? Text { get; set; }
 
    [Parameter]
    public Icon? IconStart { get; set; }
 
    [Parameter]
    public string? IconStartClass { get; set; }
 
    [Parameter]
    public Color? IconStartColor { get; set; }
 
    [Parameter]
    public string? IconStartCustomColor { get; set; }
 
    [Parameter]
    public Icon? Icon { get; set; }
 
    [Parameter]
    public Color? IconColor { get; set; }
 
    [Parameter]
    public string? IconCustomColor { get; set; }
 
    [Parameter]
    public string? ButtonClass { get; set; }
 
    /// <summary>
    /// Gets or sets the callback that provides menu items when the menu is opened.
    /// </summary>
    [Parameter]
    public required Func<IList<MenuButtonItem>> ItemsProvider { get; set; }
 
    // Exposed only for tests to inspect the rendered menu items.
    internal IReadOnlyList<MenuButtonItem> Items => _items;
 
    [Parameter]
    public Appearance? ButtonAppearance { get; set; }
 
    [Parameter]
    public string? Title { get; set; }
 
    [Parameter]
    public string MenuButtonId { get; set; } = Identifier.NewId();
 
    [Parameter]
    public bool HideIcon { get; set; }
 
    /// <summary>
    /// Gets or sets a value indicating whether the button is unconditionally disabled.
    /// </summary>
    /// <remarks>
    /// This is independent of the automatic disabling that happens when the menu has no actionable items.
    /// </remarks>
    [Parameter]
    public bool Disabled { get; set; }
 
    /// <summary>
    /// Gets or sets a value indicating whether focus should return to this menu button after a menu item is clicked.
    /// </summary>
    /// <remarks>
    /// Focus restoration is enabled by default because the underlying menu anchor is the button that opened the menu.
    /// </remarks>
    [Parameter]
    public bool RestoreFocusOnItemClick { get; set; } = true;
 
    [Inject]
    public required IJSRuntime JS { get; init; }
 
    protected override void OnParametersSet()
    {
        _icon = Icon ?? s_defaultIcon;
 
        if (!ReferenceEquals(_renderedItemsProvider, ItemsProvider))
        {
            _renderedItemsProvider = ItemsProvider;
 
            // The provider hasn't run for this delegate yet, so the menu contents are unknown.
            // Assume it has content so only Disabled can disable the button before the first open.
            _hasActionableItems = true;
        }
 
        if (_visible || _openWhenMenuRenderCompletes)
        {
            RefreshItems();
 
            if (_disabled)
            {
                OnMenuOpenChanged(false);
            }
        }
        else
        {
            UpdateDisabled();
        }
    }
 
    private async Task ToggleMenu()
    {
        if (_visible)
        {
            OnMenuOpenChanged(false);
            return;
        }
 
        if (_renderMenu && !_menuRenderComplete)
        {
            _openWhenMenuRenderCompletes = true;
            return;
        }
 
        if (!_menuRenderComplete)
        {
            // Keep the menu out of the render tree until observation is ready so a parent render
            // during the lazy module import can't complete menu rendering before this setup.
            _jsModule ??= await JS.InvokeAsync<IJSObjectReference>("import", "./Components/Controls/AspireMenuButton.razor.js");
            await _jsModule.InvokeVoidAsync("prepareForFluentMenuInitialization", MenuButtonId);
        }
 
        RefreshItems();
 
        _renderMenu = true;
 
        // Reopen a retained menu immediately, but defer the first open until FluentMenu has
        // rendered and initialized its JavaScript modules.
        if (_menuRenderComplete)
        {
            _visible = true;
        }
        else
        {
            _openWhenMenuRenderCompletes = true;
        }
    }
 
    private void RefreshItems()
    {
        _items = ItemsProvider().ToArray();
        _hasActionableItems = _items.Any(i => !i.IsDivider);
        UpdateDisabled();
    }
 
    private void UpdateDisabled()
    {
        _disabled = Disabled || !_hasActionableItems;
    }
 
    private async Task OnMenuRenderComplete()
    {
        // FluentMenu writes aria-expanded after its JavaScript modules are initialized.
        // Wait for that signal before opening the menu.
        await _jsModule!.InvokeVoidAsync("waitForFluentMenuInitialization", MenuButtonId, InitializationWaitMilliseconds);
        _menuRenderComplete = true;
 
        if (_openWhenMenuRenderCompletes)
        {
            OnMenuOpenChanged(true);
        }
    }
 
    private void OnMenuOpenChanged(bool open)
    {
        _openWhenMenuRenderCompletes = false;
        _visible = open;
    }
 
    private void OnKeyDown(KeyboardEventArgs args)
    {
        if (args is not null && args.Key == "Escape")
        {
            OnMenuOpenChanged(false);
        }
    }
 
    public async ValueTask DisposeAsync()
    {
        if (_jsModule is { } jsModule)
        {
            if (_renderMenu && !_menuRenderComplete)
            {
                try
                {
                    await jsModule.InvokeVoidAsync("cancelFluentMenuInitialization", MenuButtonId);
                }
                catch (JSDisconnectedException)
                {
                    // The browser may already be gone when the component is disposed.
                }
                catch (OperationCanceledException)
                {
                    // The browser may already be gone when the component is disposed.
                }
            }
 
            await JSInteropHelpers.SafeDisposeAsync(jsModule);
        }
    }
}