Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
@inherits FluentComponentBase

@* aspire-menu-container is added to the div parent of FluentMenu, not the FluentMenu *@
<FluentMenu Class="aspire-menu-container" @ref="_menu" Anchor="@Anchor" Anchored="@Anchored" Open="@Open" OpenChanged="OnOpenChanged" Style="@Style" VerticalThreshold="@CalculatedVerticalThreshold" HorizontalThreshold="200">
<FluentMenu Id="@_menuId" Class="aspire-menu-container" @ref="_menu" Anchor="@Anchor" Anchored="@Anchored" Open="@Open" OpenChanged="OnOpenChanged" Style="@Style" VerticalThreshold="@CalculatedVerticalThreshold" HorizontalThreshold="200">
@foreach (var item in Items)
{
@RenderMenuItem(item)
Expand Down
81 changes: 67 additions & 14 deletions src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
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 partial class AspireMenu : FluentComponentBase, IAsyncDisposable
{
private FluentMenu? _menu;
private readonly string _menuId = Identifier.NewId();
private DotNetObjectReference<AspireMenu>? _menuReference;
private string? _registeredAnchorId;

[Parameter]
public string? Anchor { get; set; }
Expand All @@ -33,20 +37,38 @@ public partial class AspireMenu : FluentComponentBase
[Parameter]
public required IReadOnlyList<MenuButtonItem> Items { get; set; }

[Inject]
public required IJSRuntime JS { 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);

public async Task CloseAsync()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_menu is { } menu)
if (_registeredAnchorId is not null && (!Open || _registeredAnchorId != Anchor))
{
await menu.CloseAsync();
await DisposeKeyboardNavigationAsync();
}

if (Open && Anchored && _registeredAnchorId is null && !string.IsNullOrEmpty(Anchor))
{
var anchor = Anchor;
_registeredAnchorId = anchor;
_menuReference ??= DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("initializeAspirePopupKeyboardNavigation", anchor, _menuId, _menuReference, new { tabExitsAlways = true });
}
}

[JSInvokable]
public async Task CloseAsync()
{
await SetOpenAsync(false);
StateHasChanged();
}

public async Task OpenAsync(int screenWidth, int screenHeight, int clientX, int clientY)
{
if (_menu is { } menu)
Expand Down Expand Up @@ -89,11 +111,7 @@ public async Task OpenAsync(int screenWidth, int screenHeight, int clientX, int
.AddStyle("min-width", "64px")
.Build();

Open = true;
if (OpenChanged.HasDelegate)
{
await OpenChanged.InvokeAsync(Open);
}
await SetOpenAsync(true);

StateHasChanged();
}
Expand All @@ -105,15 +123,50 @@ private async Task HandleItemClicked(MenuButtonItem item)
{
await onClick();
}
Open = false;

await SetOpenAsync(false);
}

private Task OnOpenChanged(bool open)
private async Task OnOpenChanged(bool open)
{
await SetOpenAsync(open);
}

private async Task SetOpenAsync(bool open)
{
if (!open)
{
await DisposeKeyboardNavigationAsync();
}

Open = open;

return OpenChanged.HasDelegate
? OpenChanged.InvokeAsync(open)
: Task.CompletedTask;
if (OpenChanged.HasDelegate)
{
await OpenChanged.InvokeAsync(open);
}
}

private async ValueTask DisposeKeyboardNavigationAsync()
{
if (_registeredAnchorId is not null)
{
var registeredAnchorId = _registeredAnchorId;
_registeredAnchorId = null;
try
{
await JS.InvokeVoidAsync("disposeAspirePopupKeyboardNavigation", registeredAnchorId, _menuId);
}
catch (JSDisconnectedException)
{
// Disposal can run while the Blazor circuit is disconnecting; the browser will drop the listener with the page.
}
}
}

public async ValueTask DisposeAsync()
{
await DisposeKeyboardNavigationAsync();
_menuReference?.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@namespace Aspire.Dashboard.Components

<div id="@_popupId">
@ChildContent
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components;
using Microsoft.FluentUI.AspNetCore.Components;
using Microsoft.JSInterop;

namespace Aspire.Dashboard.Components;

public partial class AspirePopupFocusNavigation : ComponentBase, IAsyncDisposable
{
private readonly string _popupId = Identifier.NewId();
private DotNetObjectReference<AspirePopupFocusNavigation>? _popupReference;
private string? _registeredAnchorId;

[Parameter]
public required string AnchorId { get; set; }

[Parameter]
public bool Open { get; set; }

[Parameter]
public EventCallback<bool> OpenChanged { get; set; }

[Parameter]
public RenderFragment? ChildContent { get; set; }

[Inject]
public required IJSRuntime JS { get; init; }

protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_registeredAnchorId is not null && (!Open || _registeredAnchorId != AnchorId))
{
await DisposeKeyboardNavigationAsync();
}

if (Open && _registeredAnchorId is null && !string.IsNullOrEmpty(AnchorId))
{
var anchorId = AnchorId;
_registeredAnchorId = anchorId;
_popupReference ??= DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("initializeAspirePopupKeyboardNavigation", anchorId, _popupId, _popupReference, new { tabExitsAlways = false });
}
}

[JSInvokable]
public async Task CloseAsync()
{
await DisposeKeyboardNavigationAsync();
Open = false;

if (OpenChanged.HasDelegate)
{
await OpenChanged.InvokeAsync(false);
}
}

private async ValueTask DisposeKeyboardNavigationAsync()
{
if (_registeredAnchorId is not null)
{
var registeredAnchorId = _registeredAnchorId;
_registeredAnchorId = null;
try
{
await JS.InvokeVoidAsync("disposeAspirePopupKeyboardNavigation", registeredAnchorId, _popupId);
}
catch (JSDisconnectedException)
{
// Disposal can run while the Blazor circuit is disconnecting; the browser will drop the listener with the page.
}
}
}

public async ValueTask DisposeAsync()
{
await DisposeKeyboardNavigationAsync();
_popupReference?.Dispose();
}
}
36 changes: 19 additions & 17 deletions src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor
Original file line number Diff line number Diff line change
Expand Up @@ -59,23 +59,25 @@
<FluentPopover AnchorId="@id" @bind-Open="context.PopupVisible" VerticalThreshold="@(Math.Min((context.Values.Count + 1) * 36 + 48, 300))" AutoFocus="false">
<Header>@context.Name</Header>
<Body>
<FluentStack Orientation="Orientation.Vertical" Class="dimension-popup">
<FluentCheckbox Label="@Loc[nameof(ControlsStrings.LabelAll)]"
ThreeState="true"
ShowIndeterminate="false"
ThreeStateOrderUncheckToIntermediate="true"
@bind-CheckState:get="context.AreAllValuesSelected"
@bind-CheckState:set="@(v => OnAllValuesSelectionChangedAsync(context, v))"/>
@foreach (var tag in context.Values.OrderBy(v => v.Text))
{
var isChecked = context.SelectedValues.Contains(tag);
<FluentCheckbox Label="@tag.Text"
title="@tag.Text"
@key=tag
@bind-Value:get="isChecked"
@bind-Value:set="c => OnTagSelectionChangedAsync(context, tag, c)"/>
}
</FluentStack>
<AspirePopupFocusNavigation AnchorId="@id" @bind-Open="context.PopupVisible">
<FluentStack Orientation="Orientation.Vertical" Class="dimension-popup">
<FluentCheckbox Label="@Loc[nameof(ControlsStrings.LabelAll)]"
ThreeState="true"
ShowIndeterminate="false"
ThreeStateOrderUncheckToIntermediate="true"
@bind-CheckState:get="context.AreAllValuesSelected"
@bind-CheckState:set="@(v => OnAllValuesSelectionChangedAsync(context, v))"/>
@foreach (var tag in context.Values.OrderBy(v => v.Text))
{
var isChecked = context.SelectedValues.Contains(tag);
<FluentCheckbox Label="@tag.Text"
title="@tag.Text"
@key=tag
@bind-Value:get="isChecked"
@bind-Value:set="c => OnTagSelectionChangedAsync(context, tag, c)"/>
}
</FluentStack>
</AspirePopupFocusNavigation>
</Body>
</FluentPopover>
</AspireTemplateColumn>
Expand Down
12 changes: 7 additions & 5 deletions src/Aspire.Dashboard/Components/Pages/Resources.razor
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,13 @@
FixedPlacement="true"
Class="resources-filter-popup">
<Body>
<ResourceFilters ResourceStates="@PageViewModel.ResourceStatesToVisibility"
ResourceTypes="@PageViewModel.ResourceTypesToVisibility"
ResourceHealthStates="@PageViewModel.ResourceHealthStatusesToVisibility"
OnAllFilterVisibilityCheckedChangedAsync="@OnAllFilterVisibilityCheckedChangedAsync"
OnResourceFilterVisibilityChangedAsync="@OnResourceFilterVisibilityChangedAsync" />
<AspirePopupFocusNavigation AnchorId="resourceFilterButton" @bind-Open="_isFilterPopupVisible">
<ResourceFilters ResourceStates="@PageViewModel.ResourceStatesToVisibility"
ResourceTypes="@PageViewModel.ResourceTypesToVisibility"
ResourceHealthStates="@PageViewModel.ResourceHealthStatusesToVisibility"
OnAllFilterVisibilityCheckedChangedAsync="@OnAllFilterVisibilityCheckedChangedAsync"
OnResourceFilterVisibilityChangedAsync="@OnResourceFilterVisibilityChangedAsync" />
</AspirePopupFocusNavigation>
</Body>
</FluentPopover>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,17 @@ else if (DisplayedUrls.Count > 1)
@Loc[nameof(Resources.Columns.UrlsColumnDisplayOverflowTitle)]
</Header>
<Body>
<div class="url-popup">
@foreach (var item in items)
{
var d = (DisplayedUrl)item.Data!;
<div class="url-link">
@WriteUrl(d)
</div>
}
</div>
<AspirePopupFocusNavigation AnchorId="@overflow.IdMoreButton" @bind-Open="_popoverVisible">
<div class="url-popup">
@foreach (var item in items)
{
var d = (DisplayedUrl)item.Data!;
<div class="url-link">
@WriteUrl(d)
</div>
}
</div>
</AspirePopupFocusNavigation>
</Body>
</FluentPopover>
</div>
Expand Down
Loading
Loading