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..939b4216950 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 && 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) @@ -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..d9861afcd19 100644 --- a/src/Aspire.Dashboard/wwwroot/js/app.js +++ b/src/Aspire.Dashboard/wwwroot/js/app.js @@ -330,6 +330,232 @@ window.focusElement = function (selector) { } }; +const aspirePopupKeyboardNavigationState = new Map(); + +window.initializeAspirePopupKeyboardNavigation = function (anchorId, popupId, dotNetHelper, options) { + window.disposeAspirePopupKeyboardNavigation(anchorId, popupId); + + const anchorElement = document.getElementById(anchorId); + 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; + if (ev.key !== "Tab" && !isEscape) { + return; + } + + if (isEscape) { + stopPopupKeyboardEvent(ev); + anchorElement.focus(); + dotNetHelper.invokeMethodAsync("CloseAsync"); + return; + } + + if (tabExitsAlways) { + const popupElement = resolvePopupElement(); + stopPopupKeyboardEvent(ev); + if (ev.shiftKey) { + anchorElement.focus(); + } else { + focusNextElementAfterAnchor(anchorElement, popupElement); + } + dotNetHelper.invokeMethodAsync("CloseAsync"); + return; + } + + const popupElement = resolvePopupElement(); + if (!popupElement) { + 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 popupElement = resolvePopupElement(); + if (!popupElement) { + return; + } + + const firstFocusable = getAspireFocusableElements(popupElement)[0]; + if (firstFocusable) { + stopPopupKeyboardEvent(ev); + firstFocusable.focus(); + } else { + stopPopupKeyboardEvent(ev); + focusNextElementAfterAnchor(anchorElement, popupElement); + dotNetHelper.invokeMethodAsync("CloseAsync"); + } + }; + + 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 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(key, { + anchorElement, + documentKeydownListener + }); +}; + +window.disposeAspirePopupKeyboardNavigation = function (anchorId, popupId) { + const key = getAspirePopupKeyboardNavigationKey(anchorId, popupId); + const state = aspirePopupKeyboardNavigationState.get(key); + if (!state) { + return; + } + + document.removeEventListener("keydown", state.documentKeydownListener, 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..949fcb94a4a --- /dev/null +++ b/tests/Aspire.Dashboard.Components.Tests/Controls/AspireMenuTests.cs @@ -0,0 +1,145 @@ +// 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 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() + { + 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/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 => 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); 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) 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) + ]; + } +}