From ff05874606bf51cd41a1a7bbb17a377a9033afed Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 22 Jul 2026 11:47:15 -0400 Subject: [PATCH] Restore focus after dashboard menu selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2e873eb1-f464-47b9-8eed-0d2dd7b2fe43 --- .../Components/Controls/AspireMenu.razor.cs | 11 +++++++---- .../Controls/AspireMenuButton.razor.cs | 5 ++--- .../Controls/AspireMenuTests.cs | 16 +++++----------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs index 2934332ffcf..33b818f61ab 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenu.razor.cs @@ -114,16 +114,19 @@ public async Task OpenAsync(int screenWidth, int screenHeight, int clientX, int private async Task HandleItemClicked(MenuButtonItem item) { - if (item.OnClick is {} onClick) - { - await onClick(); - } 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 OnOpenChanged(bool open) diff --git a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs index 511444e755a..1443388f52d 100644 --- a/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs +++ b/src/Aspire.Dashboard/Components/Controls/AspireMenuButton.razor.cs @@ -55,11 +55,10 @@ public partial class AspireMenuButton : FluentComponentBase /// Gets or sets a value indicating whether focus should return to this menu button after a menu item is clicked. /// /// - /// Use this for button-anchored menus because the underlying menu anchor is the element that opened the menu. - /// Do not use this behavior for cursor-positioned or context menus where the anchor is only used for positioning. + /// Focus restoration is enabled by default because the underlying menu anchor is the button that opened the menu. /// [Parameter] - public bool RestoreFocusOnItemClick { get; set; } + public bool RestoreFocusOnItemClick { get; set; } = true; protected override void OnParametersSet() { diff --git a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs index 98fe00c07a8..dcc2e92c246 100644 --- a/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs @@ -50,7 +50,7 @@ public async Task DisposeAsync_RemovesFluentMenuFromMenuProvider() } [Fact] - public void ClickItem_RestoreFocusOnItemClickTrue_FocusesAnchor() + public void ClickItem_MenuButton_FocusesAnchorBeforeOnClick() { FluentUISetupHelpers.AddCommonDashboardServices(this); FluentUISetupHelpers.SetupFluentUIComponents(this); @@ -61,7 +61,7 @@ public void ClickItem_RestoreFocusOnItemClickTrue_FocusesAnchor() var anchor = "view-options-button"; var itemClicked = false; var focusElementInvocationHandler = JSInterop.SetupVoid("focusElement", anchor); - var focusElementInvocationsDuringOnClick = -1; + focusElementInvocationHandler.SetVoidResult(); var items = new List { new() @@ -69,10 +69,7 @@ public void ClickItem_RestoreFocusOnItemClickTrue_FocusesAnchor() Text = "Show hidden resources", OnClick = () => { - focusElementInvocationsDuringOnClick = focusElementInvocationHandler.Invocations.Count; - Assert.True( - focusElementInvocationsDuringOnClick == 0, - $"Focus should not be restored until item OnClick completes. Actual focusElement invocations during OnClick: {focusElementInvocationsDuringOnClick}."); + Assert.Single(focusElementInvocationHandler.Invocations); itemClicked = true; return Task.CompletedTask; @@ -88,7 +85,6 @@ public void ClickItem_RestoreFocusOnItemClickTrue_FocusesAnchor() builder.AddAttribute(2, nameof(AspireMenuButton.MenuButtonId), anchor); builder.AddAttribute(3, nameof(AspireMenuButton.Title), "View options"); builder.AddAttribute(4, nameof(AspireMenuButton.Items), items); - builder.AddAttribute(5, nameof(AspireMenuButton.RestoreFocusOnItemClick), true); builder.CloseComponent(); }); @@ -96,16 +92,13 @@ public void ClickItem_RestoreFocusOnItemClickTrue_FocusesAnchor() cut.WaitForElement("fluent-menu-item").Click(); Assert.True(itemClicked); - Assert.True( - focusElementInvocationsDuringOnClick == 0, - $"Expected zero focusElement invocations during item OnClick, but captured {focusElementInvocationsDuringOnClick}."); var invocation = Assert.Single(focusElementInvocationHandler.Invocations); Assert.Collection(invocation.Arguments, argument => Assert.Equal(anchor, Assert.IsType(argument))); } [Fact] - public void ClickItem_RestoreFocusOnItemClickFalse_DoesNotFocusAnchor() + public void ClickItem_MenuButtonWithFocusRestorationDisabled_DoesNotFocusAnchor() { FluentUISetupHelpers.AddCommonDashboardServices(this); FluentUISetupHelpers.SetupFluentUIComponents(this); @@ -136,6 +129,7 @@ public void ClickItem_RestoreFocusOnItemClickFalse_DoesNotFocusAnchor() builder.AddAttribute(2, nameof(AspireMenuButton.MenuButtonId), anchor); builder.AddAttribute(3, nameof(AspireMenuButton.Title), "View options"); builder.AddAttribute(4, nameof(AspireMenuButton.Items), items); + builder.AddAttribute(5, nameof(AspireMenuButton.RestoreFocusOnItemClick), false); builder.CloseComponent(); });