From 49a462f578dc1a54e7777752a0b3dfb3fb14e41b Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Mon, 1 Jun 2026 07:14:54 -0400 Subject: [PATCH 1/6] Fix popup tab focus navigation Add opt-in Aspire popup focus navigation for menus and filter/URL popovers so Tab and Shift+Tab close or move focus predictably instead of resetting to the first page control. Cover JS interop wiring and lifecycle behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Components/Controls/AspireMenu.razor | 2 +- .../Components/Controls/AspireMenu.razor.cs | 81 +++++-- .../Controls/AspirePopupFocusNavigation.razor | 5 + .../AspirePopupFocusNavigation.razor.cs | 81 +++++++ .../Controls/Chart/ChartFilters.razor | 36 ++-- .../Components/Pages/Resources.razor | 12 +- .../UrlsColumnDisplay.razor | 20 +- src/Aspire.Dashboard/wwwroot/js/app.js | 199 ++++++++++++++++++ .../Controls/AspireMenuTests.cs | 116 ++++++++++ .../AspirePopupFocusNavigationTests.cs | 70 ++++++ .../Pages/ResourcesTests.cs | 48 +++++ .../Shared/FluentUISetupHelpers.cs | 3 + 12 files changed, 627 insertions(+), 46 deletions(-) create mode 100644 src/Aspire.Dashboard/Components/Controls/AspirePopupFocusNavigation.razor create mode 100644 src/Aspire.Dashboard/Components/Controls/AspirePopupFocusNavigation.razor.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs create mode 100644 tests/Aspire.Dashboard.Components.Tests/Controls/AspirePopupFocusNavigationTests.cs diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor index ed71ed7ea40..5ad102163dd 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor @@ -3,7 +3,7 @@ @inherits FluentComponentBase @* aspire-menu-container is added to the div parent of FluentMenu, not the FluentMenu *@ - + @foreach (var item in Items) { @RenderMenuItem(item) diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs index f68639c7b93..fc99db9b960 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs @@ -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? _menuReference; + private string? _registeredAnchorId; [Parameter] public string? Anchor { get; set; } @@ -33,20 +37,38 @@ public partial class AspireMenu : FluentComponentBase [Parameter] public required IReadOnlyList 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 && _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) @@ -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(); } @@ -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(); } } diff --git a/src/Aspire.Dashboard/Components/Controls/AspirePopupFocusNavigation.razor b/src/Aspire.Dashboard/Components/Controls/AspirePopupFocusNavigation.razor new file mode 100644 index 00000000000..009a10ec530 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/AspirePopupFocusNavigation.razor @@ -0,0 +1,5 @@ +@namespace Aspire.Dashboard.Components + +
+ @ChildContent +
diff --git a/src/Aspire.Dashboard/Components/Controls/AspirePopupFocusNavigation.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspirePopupFocusNavigation.razor.cs new file mode 100644 index 00000000000..7d63331c4e6 --- /dev/null +++ b/src/Aspire.Dashboard/Components/Controls/AspirePopupFocusNavigation.razor.cs @@ -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? _popupReference; + private string? _registeredAnchorId; + + [Parameter] + public required string AnchorId { get; set; } + + [Parameter] + public bool Open { get; set; } + + [Parameter] + public EventCallback 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(); + } +} diff --git a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor index 829fc2d9c9b..92c4b19cfa8 100644 --- a/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor +++ b/src/Aspire.Dashboard/Components/Controls/Chart/ChartFilters.razor @@ -59,23 +59,25 @@
@context.Name
- - - @foreach (var tag in context.Values.OrderBy(v => v.Text)) - { - var isChecked = context.SelectedValues.Contains(tag); - - } - + + + + @foreach (var tag in context.Values.OrderBy(v => v.Text)) + { + var isChecked = context.SelectedValues.Contains(tag); + + } + +
diff --git a/src/Aspire.Dashboard/Components/Pages/Resources.razor b/src/Aspire.Dashboard/Components/Pages/Resources.razor index 6ab3eb25d92..28ce479caa3 100644 --- a/src/Aspire.Dashboard/Components/Pages/Resources.razor +++ b/src/Aspire.Dashboard/Components/Pages/Resources.razor @@ -77,11 +77,13 @@ FixedPlacement="true" Class="resources-filter-popup"> - + + + diff --git a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor index 719b95b2052..44dba4a242e 100644 --- a/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor +++ b/src/Aspire.Dashboard/Components/ResourcesGridColumns/UrlsColumnDisplay.razor @@ -41,15 +41,17 @@ else if (DisplayedUrls.Count > 1) @Loc[nameof(Resources.Columns.UrlsColumnDisplayOverflowTitle)] -
- @foreach (var item in items) - { - var d = (DisplayedUrl)item.Data!; - - } -
+ +
+ @foreach (var item in items) + { + var d = (DisplayedUrl)item.Data!; + + } +
+
diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index ee7ddcff4fc..60e13f11a08 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -330,6 +330,205 @@ window.focusElement = function (selector) { } }; +const aspirePopupKeyboardNavigationState = new Map(); + +window.initializeAspirePopupKeyboardNavigation = function (anchorId, popupId, dotNetHelper, options) { + window.disposeAspirePopupKeyboardNavigation(anchorId, popupId); + + const anchorElement = document.getElementById(anchorId); + const popupElement = document.getElementById(popupId); + if (!anchorElement || !popupElement) { + return; + } + + const tabExitsAlways = options?.tabExitsAlways ?? options?.TabExitsAlways ?? false; + + const popupKeydownListener = function (ev) { + const isEscape = ev.key === "Escape" || ev.keyCode === 27; + if (ev.key !== "Tab" && !isEscape) { + return; + } + + if (isEscape) { + stopPopupKeyboardEvent(ev); + anchorElement.focus(); + dotNetHelper.invokeMethodAsync("CloseAsync"); + return; + } + + if (tabExitsAlways) { + stopPopupKeyboardEvent(ev); + if (ev.shiftKey) { + anchorElement.focus(); + } else { + focusNextElementAfterAnchor(anchorElement, popupElement); + } + dotNetHelper.invokeMethodAsync("CloseAsync"); + return; + } + + const focusableElements = getAspireFocusableElements(popupElement); + const activeIndex = findAspireActiveElementIndex(focusableElements); + + if (!ev.shiftKey && (focusableElements.length === 0 || activeIndex === focusableElements.length - 1)) { + stopPopupKeyboardEvent(ev); + focusNextElementAfterAnchor(anchorElement, popupElement); + dotNetHelper.invokeMethodAsync("CloseAsync"); + } else if (ev.shiftKey && (focusableElements.length === 0 || activeIndex === 0)) { + stopPopupKeyboardEvent(ev); + anchorElement.focus(); + dotNetHelper.invokeMethodAsync("CloseAsync"); + } + }; + + const anchorKeydownListener = function (ev) { + if (ev.key !== "Tab") { + return; + } + + if (ev.shiftKey) { + dotNetHelper.invokeMethodAsync("CloseAsync"); + return; + } + + const firstFocusable = getAspireFocusableElements(popupElement)[0]; + if (firstFocusable) { + stopPopupKeyboardEvent(ev); + firstFocusable.focus(); + } else { + stopPopupKeyboardEvent(ev); + focusNextElementAfterAnchor(anchorElement, popupElement); + dotNetHelper.invokeMethodAsync("CloseAsync"); + } + }; + + // Fluent UI's popup keyboard helper currently calculates the next page element from + // the inner shadow DOM button for fluent-button anchors. That inner element is not in + // document order, so Tab can wrap to the first focusable control on the page. Capture + // Tab for Aspire-owned popups before Fluent UI's listener and calculate from the host. + popupElement.addEventListener("keydown", popupKeydownListener, true); + anchorElement.addEventListener("keydown", anchorKeydownListener, true); + + aspirePopupKeyboardNavigationState.set(getAspirePopupKeyboardNavigationKey(anchorId, popupId), { + anchorElement, + anchorKeydownListener, + popupElement, + popupKeydownListener + }); +}; + +window.disposeAspirePopupKeyboardNavigation = function (anchorId, popupId) { + const key = getAspirePopupKeyboardNavigationKey(anchorId, popupId); + const state = aspirePopupKeyboardNavigationState.get(key); + if (!state) { + return; + } + + state.popupElement.removeEventListener("keydown", state.popupKeydownListener, true); + state.anchorElement.removeEventListener("keydown", state.anchorKeydownListener, true); + aspirePopupKeyboardNavigationState.delete(key); +}; + +function getAspirePopupKeyboardNavigationKey(anchorId, popupId) { + return `${anchorId}:${popupId}`; +} + +function stopPopupKeyboardEvent(ev) { + ev.preventDefault(); + ev.stopPropagation(); + ev.stopImmediatePropagation(); +} + +function getAspireFocusableElements(container, excludedContainer) { + const focusableSelector = "input, select, textarea, button, object, a[href], area[href], iframe, summary, [tabindex], [contenteditable='true']"; + const focusableElements = []; + + for (const element of container.querySelectorAll("*")) { + if (excludedContainer?.contains(element)) { + continue; + } + + if (isAspireFocusableElement(element, focusableSelector)) { + focusableElements.push(element); + } + } + + return focusableElements; +} + +function isAspireFocusableElement(element, focusableSelector) { + const tagName = element.tagName.toLowerCase(); + const isFluentInteractiveElement = tagName === "fluent-anchor" + || tagName === "fluent-button" + || tagName === "fluent-checkbox" + || tagName === "fluent-menu-item" + || tagName === "fluent-radio" + || tagName === "fluent-search" + || tagName === "fluent-select" + || tagName === "fluent-switch" + || tagName === "fluent-tab" + || tagName === "fluent-text-field"; + + if (!isFluentInteractiveElement && !element.matches(focusableSelector)) { + return false; + } + + if (!isFluentInteractiveElement && element.tabIndex < 0) { + return false; + } + + if (element.disabled || element.getAttribute("aria-disabled") === "true") { + return false; + } + + return isAspireElementVisible(element); +} + +function isAspireElementVisible(element) { + if (typeof element.checkVisibility === "function") { + return element.checkVisibility(); + } + + return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); +} + +function findAspireActiveElementIndex(focusableElements) { + const activeElement = document.activeElement; + const deepActiveElement = getAspireDeepActiveElement(); + + return focusableElements.findIndex(element => + element === activeElement + || element === deepActiveElement + || element.contains(deepActiveElement) + || element.shadowRoot?.contains(deepActiveElement)); +} + +function getAspireDeepActiveElement() { + let activeElement = document.activeElement; + while (activeElement?.shadowRoot?.activeElement) { + activeElement = activeElement.shadowRoot.activeElement; + } + + return activeElement; +} + +function focusNextElementAfterAnchor(anchorElement, popupElement) { + const root = anchorElement.getRootNode() instanceof Document + ? anchorElement.getRootNode().body + : document.body; + const focusableElements = getAspireFocusableElements(root, popupElement); + const anchorIndex = focusableElements.indexOf(anchorElement); + + if (anchorIndex >= 0) { + (focusableElements[anchorIndex + 1] ?? anchorElement).focus(); + return; + } + + const nextElement = focusableElements.find(element => + (anchorElement.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0); + (nextElement ?? anchorElement).focus(); +} + window.getWindowDimensions = function() { return { width: window.innerWidth, diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs new file mode 100644 index 00000000000..082ffd8cd9d --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs @@ -0,0 +1,116 @@ +// 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.Components.Tests.Shared; +using Aspire.Dashboard.Model; +using Bunit; +using Microsoft.FluentUI.AspNetCore.Components; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +public class AspireMenuTests : DashboardTestContext +{ + [Fact] + public void OpenMenu_InitializesKeyboardNavigation() + { + FluentUISetupHelpers.AddCommonDashboardServices(this); + FluentUISetupHelpers.SetupFluentUIComponents(this); + FluentUISetupHelpers.SetupFluentMenu(this); + FluentUISetupHelpers.SetupFluentAnchoredRegion(this); + FluentUISetupHelpers.SetupFluentButton(this); + + var anchor = "view-options-button"; + var items = new List + { + new() + { + Text = "Show hidden resources", + OnClick = () => Task.CompletedTask + } + }; + + var cut = Render(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + builder.OpenComponent(1); + builder.AddAttribute(2, nameof(AspireMenuButton.MenuButtonId), anchor); + builder.AddAttribute(3, nameof(AspireMenuButton.Title), "View options"); + builder.AddAttribute(4, nameof(AspireMenuButton.Items), items); + builder.CloseComponent(); + }); + + cut.Find($"#{anchor}").Click(); + + var invocation = JSInterop.Invocations.Last(invocation => invocation.Identifier == "initializeAspirePopupKeyboardNavigation"); + Assert.Collection(invocation.Arguments, + argument => Assert.Equal(anchor, Assert.IsType(argument)), + argument => Assert.False(string.IsNullOrEmpty(Assert.IsType(argument))), + AssertNotNull, + argument => + { + var options = Assert.IsAssignableFrom(argument); + Assert.True(GetTabExitsAlways(options)); + }); + } + + [Fact] + public async Task OpenMenu_DisposesKeyboardNavigationWithRegisteredAnchorWhenAnchorChanges() + { + FluentUISetupHelpers.AddCommonDashboardServices(this); + FluentUISetupHelpers.SetupFluentUIComponents(this); + FluentUISetupHelpers.SetupFluentMenu(this); + FluentUISetupHelpers.SetupFluentAnchoredRegion(this); + JSInterop.SetupVoid("initializeAspirePopupKeyboardNavigation", _ => true); + JSInterop.SetupVoid("disposeAspirePopupKeyboardNavigation", _ => true); + + var items = new List + { + new() + { + Text = "Show hidden resources", + OnClick = () => Task.CompletedTask + } + }; + + var cut = RenderComponent(builder => + { + builder.Add(p => p.Anchor, "view-options-button"); + builder.Add(p => p.Open, true); + builder.Add(p => p.Items, items); + }); + await Task.Yield(); + + var menuId = Assert.IsType(JSInterop.Invocations.Single(i => i.Identifier == "initializeAspirePopupKeyboardNavigation").Arguments[1]); + + cut.SetParametersAndRender(builder => + { + builder.Add(p => p.Anchor, "resource-filter-button"); + builder.Add(p => p.Open, true); + builder.Add(p => p.Items, items); + }); + await Task.Yield(); + + Assert.Collection(JSInterop.Invocations.Where(i => i.Identifier == "disposeAspirePopupKeyboardNavigation"), + invocation => + { + Assert.Collection(invocation.Arguments, + argument => Assert.Equal("view-options-button", Assert.IsType(argument)), + argument => Assert.Equal(menuId, Assert.IsType(argument))); + }); + } + + private static bool GetTabExitsAlways(object options) + { + var property = options.GetType().GetProperty("tabExitsAlways"); + Assert.NotNull(property); + + return Assert.IsType(property.GetValue(options)); + } + + private static void AssertNotNull(object? argument) + { + Assert.NotNull(argument); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspirePopupFocusNavigationTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspirePopupFocusNavigationTests.cs new file mode 100644 index 00000000000..632be9d88a5 --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspirePopupFocusNavigationTests.cs @@ -0,0 +1,70 @@ +// 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.Components.Tests.Shared; +using Bunit; +using Xunit; + +namespace Aspire.Dashboard.Components.Tests.Controls; + +public class AspirePopupFocusNavigationTests : DashboardTestContext +{ + [Fact] + public void OpenPopup_InitializesKeyboardNavigation() + { + FluentUISetupHelpers.AddCommonDashboardServices(this); + JSInterop.SetupVoid("initializeAspirePopupKeyboardNavigation", _ => true); + + var anchor = "resourceFilterButton"; + + RenderComponent(builder => + { + builder.Add(component => component.AnchorId, anchor); + builder.Add(component => component.Open, true); + builder.AddChildContent(""); + }); + + var invocation = JSInterop.Invocations.Last(invocation => invocation.Identifier == "initializeAspirePopupKeyboardNavigation"); + Assert.Collection(invocation.Arguments, + argument => Assert.Equal(anchor, Assert.IsType(argument)), + argument => Assert.False(string.IsNullOrEmpty(Assert.IsType(argument))), + AssertNotNull, + argument => + { + var options = Assert.IsAssignableFrom(argument); + Assert.False(GetTabExitsAlways(options)); + }); + } + + [Fact] + public async Task ClosePopup_RaisesOpenChanged() + { + FluentUISetupHelpers.AddCommonDashboardServices(this); + + var isOpen = true; + var cut = RenderComponent(builder => + { + builder.Add(component => component.AnchorId, "resourceFilterButton"); + builder.Add(component => component.Open, false); + builder.Add(component => component.OpenChanged, value => isOpen = value); + builder.AddChildContent(""); + }); + + await cut.Instance.CloseAsync(); + + Assert.False(isOpen); + } + + private static bool GetTabExitsAlways(object options) + { + var property = options.GetType().GetProperty("tabExitsAlways"); + Assert.NotNull(property); + + return Assert.IsType(property.GetValue(options)); + } + + private static void AssertNotNull(object? argument) + { + Assert.NotNull(argument); + } +} diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs index 61824f11bcc..b75a16de39a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs @@ -389,6 +389,19 @@ private static ResourceViewModel CreateResource( }; } + private static bool GetTabExitsAlways(object options) + { + var property = options.GetType().GetProperty("tabExitsAlways"); + Assert.NotNull(property); + + return Assert.IsType(property.GetValue(options)); + } + + private static void AssertNotNull(object? argument) + { + Assert.NotNull(argument); + } + [Fact] public void ViewOptionsMenuIsVisibleWhenHiddenResourcesExist() { @@ -416,6 +429,41 @@ public void ViewOptionsMenuIsVisibleWhenHiddenResourcesExist() Assert.NotNull(menuButton); } + [Fact] + public void ViewOptionsMenu_Open_InitializesKeyboardNavigation() + { + var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); + var initialResources = new List + { + CreateResource("Resource1", "Type1", "Running", null), + CreateResource("HiddenResource", "Type2", null, null, isHidden: true), + }; + var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>); + ResourceSetupHelpers.SetupResourcesPage( + this, + viewport, + dashboardClient); + + var cut = RenderComponent(builder => + { + builder.AddCascadingValue(viewport); + }); + + var menuButton = cut.FindComponent(); + cut.Find($"#{menuButton.Instance.MenuButtonId}").Click(); + + var invocation = JSInterop.Invocations.Last(invocation => invocation.Identifier == "initializeAspirePopupKeyboardNavigation"); + Assert.Collection(invocation.Arguments, + argument => Assert.Equal(menuButton.Instance.MenuButtonId, Assert.IsType(argument)), + argument => Assert.False(string.IsNullOrEmpty(Assert.IsType(argument))), + AssertNotNull, + argument => + { + var options = Assert.IsAssignableFrom(argument); + Assert.True(GetTabExitsAlways(options)); + }); + } + [Fact] public void TableView_ExcludesParameters() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs index 91d4a879be0..978c2d97180 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Shared/FluentUISetupHelpers.cs @@ -171,6 +171,9 @@ public static void AddCommonDashboardServices( context.Services.AddScoped(); context.Services.AddScoped(); context.Services.AddSingleton>(Options.Create(new DashboardOptions())); + + context.JSInterop.SetupVoid("initializeAspirePopupKeyboardNavigation", _ => true); + context.JSInterop.SetupVoid("disposeAspirePopupKeyboardNavigation", _ => true); } public static void SetupFluentUIComponents(TestContext context) From 15bdcf77bbbc3c76bf71f692135b6a358e93de3a Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 2 Jun 2026 02:48:03 -0400 Subject: [PATCH 2/6] Fix dashboard menu item tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/ConsoleLogsTests.cs | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs index d36a8f594a2..0b66ca5699d 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ConsoleLogsTests.cs @@ -168,7 +168,7 @@ public async Task ResourceName_SubscribeOnLoadAndChange_SubscribeConsoleLogsOnce } [Fact] - public void ToggleHiddenResources_HiddenResourceVisibilityAndSelection_WorksCorrectly() + public async Task ToggleHiddenResources_HiddenResourceVisibilityAndSelection_WorksCorrectly() { // Arrange var regularResource1 = ModelTestHelpers.CreateResource(resourceName: "regular-resource1", state: KnownResourceState.Running); @@ -226,16 +226,16 @@ public void ToggleHiddenResources_HiddenResourceVisibilityAndSelection_WorksCorr settingsMenuButton.Click(); // Find and click the "Show hidden resources" menu item - cut.WaitForAssertion(() => - { - var showHiddenMenuItem = cut.Find("fluent-menu-item:contains('" + Resources.ControlsStrings.ShowHiddenResources + "')"); - Assert.NotNull(showHiddenMenuItem); - showHiddenMenuItem.Click(); - }); + var settingsMenu = cut.FindComponents().Single(m => m.Instance.Items.Any(i => i.Text == Resources.ControlsStrings.ShowHiddenResources)); + var showHiddenMenuItem = settingsMenu.Instance.Items.Single(i => i.Text == Resources.ControlsStrings.ShowHiddenResources); + Assert.NotNull(showHiddenMenuItem.OnClick); + await cut.InvokeAsync(showHiddenMenuItem.OnClick); + cut.Render(); // Wait for UI to update cut.WaitForAssertion(() => { + var selectElement = cut.FindComponent().Find("fluent-select"); var updatedOptions = selectElement.QuerySelectorAll("fluent-option"); // Should now have "All" + all three resources Assert.Equal(4, updatedOptions.Length); @@ -245,20 +245,18 @@ public void ToggleHiddenResources_HiddenResourceVisibilityAndSelection_WorksCorr Assert.Contains("hidden-resource", updatedOptionValues); }); - // Act & Assert 3: Click the settings menu button again and click "Hide hidden resources" to hide them again + // Act & Assert 3: Click "Hide hidden resources" to hide them again // Note: We stay on "All" view to test the hide functionality - settingsMenuButton.Click(); - - cut.WaitForAssertion(() => - { - var hideHiddenMenuItem = cut.Find("fluent-menu-item:contains('" + Resources.ControlsStrings.HideHiddenResources + "')"); - Assert.NotNull(hideHiddenMenuItem); - hideHiddenMenuItem.Click(); - }); + settingsMenu = cut.FindComponents().Single(m => m.Instance.Items.Any(i => i.Text == Resources.ControlsStrings.HideHiddenResources)); + var hideHiddenMenuItem = settingsMenu.Instance.Items.Single(i => i.Text == Resources.ControlsStrings.HideHiddenResources); + Assert.NotNull(hideHiddenMenuItem.OnClick); + await cut.InvokeAsync(hideHiddenMenuItem.OnClick); + cut.Render(); // Wait for UI to update - hidden resource should be filtered out cut.WaitForAssertion(() => { + var selectElement = cut.FindComponent().Find("fluent-select"); var finalOptions = selectElement.QuerySelectorAll("fluent-option"); // Should be back to "All" + 2 regular resources only Assert.Equal(3, finalOptions.Length); @@ -471,7 +469,7 @@ public async Task ReadingLogs_ErrorDuringReadAfterDispose_StatusUnchanged() } [Fact] - public void ClearLogEntries_AllResources_LogsFilteredOut() + public async Task ClearLogEntries_AllResources_LogsFilteredOut() { // Arrange var consoleLogsChannel = Channel.CreateUnbounded>(); @@ -518,7 +516,11 @@ public void ClearLogEntries_AllResources_LogsFilteredOut() cut.Find(".clear-button").Click(); cut.WaitForElement("#clear-menu-all"); - cut.Find("#clear-menu-all").Click(); + var clearMenu = cut.FindComponents().Single(m => m.Instance.Items.Any(i => i.Id == "clear-menu-all")); + var clearAllMenuItem = clearMenu.Instance.Items.Single(i => i.Id == "clear-menu-all"); + Assert.NotNull(clearAllMenuItem.OnClick); + await cut.InvokeAsync(clearAllMenuItem.OnClick); + cut.Render(); cut.WaitForState(() => instance._logEntries.EntriesCount == 0); From 3dd1b535d8efd7622a7ee1aff232f343f19c3e32 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 2 Jun 2026 03:26:10 -0400 Subject: [PATCH 3/6] Fix dashboard menu callback tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Controls/ResourceDetailsTests.cs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs index 8976b5e3de6..05392d1a1d3 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/ResourceDetailsTests.cs @@ -66,9 +66,11 @@ public async Task ClickMaskAllSwitch_UpdatedResource_MaskChanged() var maskAllSwitch = cut.Find(".mask-all-switch"); - // HACK. Calling OnClick on the element isn't triggering the event correctly. Instead, call OnClick on the component. - var item = cut.FindComponents().Single(s => s.Instance.Class == maskAllSwitch.Attributes["class"]!.Value); - await cut.InvokeAsync(() => item.Instance.OnClick.InvokeAsync(new MouseEventArgs())); + // HACK. Calling OnClick on the element isn't triggering the event correctly. Instead, call OnClick on the menu item model. + var item = cut.FindComponents().SelectMany(m => m.Instance.Items).Single(s => s.Class == maskAllSwitch.Attributes["class"]!.Value); + Assert.NotNull(item.OnClick); + await cut.InvokeAsync(item.OnClick); + cut.Render(); Assert.Collection(cut.Instance.FilteredEnvironmentVariables, e => @@ -156,9 +158,11 @@ public async Task ClickMaskAllSwitch_NewResource_MaskChanged() var maskAllSwitch = cut.Find(".mask-all-switch"); - // HACK. Calling OnClick on the element isn't triggering the event correctly. Instead, call OnClick on the component. - var item = cut.FindComponents().Single(s => s.Instance.Class == maskAllSwitch.Attributes["class"]!.Value); - await cut.InvokeAsync(() => item.Instance.OnClick.InvokeAsync(new MouseEventArgs())); + // HACK. Calling OnClick on the element isn't triggering the event correctly. Instead, call OnClick on the menu item model. + var item = cut.FindComponents().SelectMany(m => m.Instance.Items).Single(s => s.Class == maskAllSwitch.Attributes["class"]!.Value); + Assert.NotNull(item.OnClick); + await cut.InvokeAsync(item.OnClick); + cut.Render(); Assert.Collection(cut.Instance.FilteredEnvironmentVariables, e => From c8b800c6a8f5366c63c4aa256841b5e82e249ee2 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 2 Jun 2026 07:38:25 -0400 Subject: [PATCH 4/6] Fix Aspire popup tab focus handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Aspire.Dashboard/wwwroot/js/app.js | 53 +++++++++++++++++++------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/Aspire.Dashboard/wwwroot/js/app.js b/src/Aspire.Dashboard/wwwroot/js/app.js index 60e13f11a08..d9861afcd19 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -336,12 +336,13 @@ window.initializeAspirePopupKeyboardNavigation = function (anchorId, popupId, do window.disposeAspirePopupKeyboardNavigation(anchorId, popupId); const anchorElement = document.getElementById(anchorId); - const popupElement = document.getElementById(popupId); - if (!anchorElement || !popupElement) { + if (!anchorElement) { return; } + const key = getAspirePopupKeyboardNavigationKey(anchorId, popupId); const tabExitsAlways = options?.tabExitsAlways ?? options?.TabExitsAlways ?? false; + const resolvePopupElement = () => document.getElementById(popupId); const popupKeydownListener = function (ev) { const isEscape = ev.key === "Escape" || ev.keyCode === 27; @@ -357,6 +358,7 @@ window.initializeAspirePopupKeyboardNavigation = function (anchorId, popupId, do } if (tabExitsAlways) { + const popupElement = resolvePopupElement(); stopPopupKeyboardEvent(ev); if (ev.shiftKey) { anchorElement.focus(); @@ -367,6 +369,11 @@ window.initializeAspirePopupKeyboardNavigation = function (anchorId, popupId, do return; } + const popupElement = resolvePopupElement(); + if (!popupElement) { + return; + } + const focusableElements = getAspireFocusableElements(popupElement); const activeIndex = findAspireActiveElementIndex(focusableElements); @@ -391,6 +398,11 @@ window.initializeAspirePopupKeyboardNavigation = function (anchorId, popupId, do return; } + const popupElement = resolvePopupElement(); + if (!popupElement) { + return; + } + const firstFocusable = getAspireFocusableElements(popupElement)[0]; if (firstFocusable) { stopPopupKeyboardEvent(ev); @@ -402,18 +414,34 @@ window.initializeAspirePopupKeyboardNavigation = function (anchorId, popupId, do } }; + const documentKeydownListener = function (ev) { + const isEscape = ev.key === "Escape" || ev.keyCode === 27; + if (ev.key !== "Tab" && !isEscape) { + return; + } + + const eventPath = typeof ev.composedPath === "function" ? ev.composedPath() : []; + const popupElement = resolvePopupElement(); + const isFromAnchor = eventPath.includes(anchorElement) || anchorElement.contains(ev.target); + const isFromPopup = popupElement && (eventPath.includes(popupElement) || popupElement.contains(ev.target)); + + if (isFromAnchor) { + anchorKeydownListener(ev); + } else if (isFromPopup) { + popupKeydownListener(ev); + } + }; + // Fluent UI's popup keyboard helper currently calculates the next page element from - // the inner shadow DOM button for fluent-button anchors. That inner element is not in - // document order, so Tab can wrap to the first focusable control on the page. Capture - // Tab for Aspire-owned popups before Fluent UI's listener and calculate from the host. - popupElement.addEventListener("keydown", popupKeydownListener, true); - anchorElement.addEventListener("keydown", anchorKeydownListener, true); + // the inner shadow DOM target for fluent-button anchors and menu items. Those inner + // elements are not in document order, so Tab can wrap to the first focusable control + // on the page. Capture Tab at the document before Fluent UI's listener and calculate + // from the stable host elements instead. + document.addEventListener("keydown", documentKeydownListener, true); - aspirePopupKeyboardNavigationState.set(getAspirePopupKeyboardNavigationKey(anchorId, popupId), { + aspirePopupKeyboardNavigationState.set(key, { anchorElement, - anchorKeydownListener, - popupElement, - popupKeydownListener + documentKeydownListener }); }; @@ -424,8 +452,7 @@ window.disposeAspirePopupKeyboardNavigation = function (anchorId, popupId) { return; } - state.popupElement.removeEventListener("keydown", state.popupKeydownListener, true); - state.anchorElement.removeEventListener("keydown", state.anchorKeydownListener, true); + document.removeEventListener("keydown", state.documentKeydownListener, true); aspirePopupKeyboardNavigationState.delete(key); }; From 0c6bcb8d2cda715a9eb02261929b2bc890b6eff6 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Tue, 2 Jun 2026 07:49:17 -0400 Subject: [PATCH 5/6] Add browser regression for resources menu focus Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Pages/ResourcesTests.cs | 48 -------------- .../Infrastructure/DashboardServerFixture.cs | 5 +- .../Infrastructure/MockDashboardClient.cs | 6 +- .../Integration/Playwright/ResourcesTests.cs | 62 +++++++++++++++++++ 4 files changed, 69 insertions(+), 52 deletions(-) create mode 100644 tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourcesTests.cs diff --git a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs index b75a16de39a..61824f11bcc 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Pages/ResourcesTests.cs @@ -389,19 +389,6 @@ private static ResourceViewModel CreateResource( }; } - private static bool GetTabExitsAlways(object options) - { - var property = options.GetType().GetProperty("tabExitsAlways"); - Assert.NotNull(property); - - return Assert.IsType(property.GetValue(options)); - } - - private static void AssertNotNull(object? argument) - { - Assert.NotNull(argument); - } - [Fact] public void ViewOptionsMenuIsVisibleWhenHiddenResourcesExist() { @@ -429,41 +416,6 @@ public void ViewOptionsMenuIsVisibleWhenHiddenResourcesExist() Assert.NotNull(menuButton); } - [Fact] - public void ViewOptionsMenu_Open_InitializesKeyboardNavigation() - { - var viewport = new ViewportInformation(IsDesktop: true, IsUltraLowHeight: false, IsUltraLowWidth: false); - var initialResources = new List - { - CreateResource("Resource1", "Type1", "Running", null), - CreateResource("HiddenResource", "Type2", null, null, isHidden: true), - }; - var dashboardClient = new TestDashboardClient(isEnabled: true, initialResources: initialResources, resourceChannelProvider: Channel.CreateUnbounded>); - ResourceSetupHelpers.SetupResourcesPage( - this, - viewport, - dashboardClient); - - var cut = RenderComponent(builder => - { - builder.AddCascadingValue(viewport); - }); - - var menuButton = cut.FindComponent(); - cut.Find($"#{menuButton.Instance.MenuButtonId}").Click(); - - var invocation = JSInterop.Invocations.Last(invocation => invocation.Identifier == "initializeAspirePopupKeyboardNavigation"); - Assert.Collection(invocation.Arguments, - argument => Assert.Equal(menuButton.Instance.MenuButtonId, Assert.IsType(argument)), - argument => Assert.False(string.IsNullOrEmpty(Assert.IsType(argument))), - AssertNotNull, - argument => - { - var options = Assert.IsAssignableFrom(argument); - Assert.True(GetTabExitsAlways(options)); - }); - } - [Fact] public void TableView_ExcludesParameters() { diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs index c7a1042f49c..d3ec48ca5f6 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/DashboardServerFixture.cs @@ -3,6 +3,7 @@ using System.Reflection; using Aspire.Dashboard.Configuration; +using Aspire.Dashboard.Model; using Aspire.Hosting; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; @@ -20,6 +21,8 @@ public class DashboardServerFixture : IAsyncLifetime // Can't have multiple fixtures when one is generic. Workaround by nesting playwright fixture. public PlaywrightFixture PlaywrightFixture { get; } + protected virtual IReadOnlyList? Resources => null; + public DashboardServerFixture() { PlaywrightFixture = new PlaywrightFixture(); @@ -56,7 +59,7 @@ public async ValueTask InitializeAsync() preConfigureBuilder: builder => { builder.Configuration.AddConfiguration(config); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(new MockDashboardClient(Resources)); }); await DashboardApp.StartAsync(); diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs index b16c7491e96..587670d6db8 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/Infrastructure/MockDashboardClient.cs @@ -29,9 +29,9 @@ public sealed class MockDashboardClient : IDashboardClient }.ToDictionary(), state: KnownResourceState.Running); - private readonly List? _resources; + private readonly IReadOnlyList? _resources; - public MockDashboardClient(List? resources = null) + public MockDashboardClient(IReadOnlyList? resources = null) { _resources = resources; } @@ -47,7 +47,7 @@ public MockDashboardClient(List? resources = null) public Task SubscribeResourcesAsync(CancellationToken cancellationToken) { return Task.FromResult(new ResourceViewModelSubscription( - [TestResource1], + [.. (_resources ?? [TestResource1])], Test() )); } diff --git a/tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourcesTests.cs b/tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourcesTests.cs new file mode 100644 index 00000000000..e82aff5a50c --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Integration/Playwright/ResourcesTests.cs @@ -0,0 +1,62 @@ +// 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.Tests.Integration.Playwright.Infrastructure; +using Aspire.TestUtilities; +using Aspire.Tests.Shared.DashboardModel; +using Microsoft.AspNetCore.InternalTesting; +using Microsoft.Playwright; +using Xunit; + +namespace Aspire.Dashboard.Tests.Integration.Playwright; + +[RequiresFeature(TestFeature.Playwright)] +public class ResourcesTests : PlaywrightTestsBase +{ + public ResourcesTests(ResourcesDashboardServerFixture dashboardServerFixture) + : base(dashboardServerFixture) + { + } + + [Fact] + [OuterloopTest("Resource-intensive Playwright browser test")] + public async Task ViewOptionsMenu_TabMovesFocusToNextLogicalControl() + { + await RunTestAsync(async page => + { + await PlaywrightFixture.GoToHomeAndWaitForDataGridLoad(page).DefaultTimeout(); + + var viewOptions = page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = Dashboard.Resources.Resources.ResourcesChangeViewOptions }); + await viewOptions.ClickAsync(); + await Assertions.Expect(page.GetByRole(AriaRole.Menu)).ToBeVisibleAsync(); + + await page.Keyboard.PressAsync("Tab"); + await page.Keyboard.PressAsync("Tab"); + + await Assertions.Expect(page.GetByRole(AriaRole.Menu)).ToBeHiddenAsync(); + var activeElementName = await page.EvaluateAsync( + """ + () => { + const activeElement = document.activeElement; + return activeElement?.getAttribute('aria-label') + ?? activeElement?.getAttribute('title') + ?? activeElement?.textContent?.trim().replace(/\s+/g, ' '); + } + """); + Assert.Equal(Dashboard.Resources.Layout.NavMenuResourcesTab, activeElementName); + }); + } + + public sealed class ResourcesDashboardServerFixture : DashboardServerFixture + { + protected override IReadOnlyList Resources => + [ + MockDashboardClient.TestResource1, + ModelTestHelpers.CreateResource( + resourceName: "HiddenResource", + resourceType: KnownResourceTypes.Container, + hidden: true) + ]; + } +} From 76abcfda6cdde9f53ab809e3a899d92f8a31ac5f Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 4 Jun 2026 13:09:57 -0400 Subject: [PATCH 6/6] Avoid popup keyboard navigation for context menus Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Components/Controls/AspireMenu.razor.cs | 2 +- .../Controls/AspireMenuTests.cs | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs index fc99db9b960..939b4216950 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs @@ -53,7 +53,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) await DisposeKeyboardNavigationAsync(); } - if (Open && _registeredAnchorId is null && !string.IsNullOrEmpty(Anchor)) + if (Open && Anchored && _registeredAnchorId is null && !string.IsNullOrEmpty(Anchor)) { var anchor = Anchor; _registeredAnchorId = anchor; diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs index 082ffd8cd9d..949fcb94a4a 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs @@ -55,6 +55,35 @@ public void OpenMenu_InitializesKeyboardNavigation() }); } + [Fact] + public async Task OpenContextMenu_DoesNotInitializeKeyboardNavigation() + { + FluentUISetupHelpers.AddCommonDashboardServices(this); + FluentUISetupHelpers.SetupFluentUIComponents(this); + FluentUISetupHelpers.SetupFluentMenu(this); + FluentUISetupHelpers.SetupFluentAnchoredRegion(this); + + var items = new List + { + new() + { + Text = "Show hidden resources", + OnClick = () => Task.CompletedTask + } + }; + + RenderComponent(builder => + { + builder.Add(p => p.Anchor, "resources-summary-layout-id"); + builder.Add(p => p.Anchored, false); + builder.Add(p => p.Open, true); + builder.Add(p => p.Items, items); + }); + await Task.Yield(); + + Assert.DoesNotContain(JSInterop.Invocations, invocation => invocation.Identifier == "initializeAspirePopupKeyboardNavigation"); + } + [Fact] public async Task OpenMenu_DisposesKeyboardNavigationWithRegisteredAnchorWhenAnchorChanges() {