Skip to content

Commit a598744

Browse files
adamintAdam RatzmanAdam RatzmanCopilot
authored
Keep mobile nav focus visible at high zoom (#18502)
* Keep mobile nav focus visible at high zoom * Add mobile nav focus browser regression * Keep mobile nav keyboard focus contained * Fix mobile nav layout test JS setup * Fix mobile nav JS test setup * Remove mobile nav focus trap * Address mobile nav keyboard review findings * Remove redundant mobile nav keyboard null path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix dashboard mobile nav keyboard handling --------- Co-authored-by: Adam Ratzman <adamratzman1@gmail.com> Co-authored-by: Adam Ratzman <adamint@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d9bdb97 commit a598744

11 files changed

Lines changed: 493 additions & 7 deletions

File tree

src/Aspire.Dashboard/BlazorAssets.targets

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,32 @@
99
NuGet package for each supported major runtime version.
1010
-->
1111

12+
<UsingTask
13+
TaskName="RemoveUnsupportedBlazorEventProperties"
14+
TaskFactory="RoslynCodeTaskFactory"
15+
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
16+
<ParameterGroup>
17+
<FilePaths ParameterType="Microsoft.Build.Framework.ITaskItem[]" Required="true" />
18+
</ParameterGroup>
19+
<Task>
20+
<Using Namespace="System.IO" />
21+
<Code Type="Fragment" Language="cs">
22+
<![CDATA[
23+
foreach (var filePath in FilePaths)
24+
{
25+
var sourceText = File.ReadAllText(filePath.ItemSpec);
26+
var updatedText = sourceText.Replace(",isComposing:t.isComposing", string.Empty);
27+
28+
if (updatedText != sourceText)
29+
{
30+
File.WriteAllText(filePath.ItemSpec, updatedText);
31+
}
32+
}
33+
]]>
34+
</Code>
35+
</Task>
36+
</UsingTask>
37+
1238
<ItemGroup>
1339
<!--
1440
Download both asset packages to the NuGet global cache (no assembly reference added).
@@ -32,6 +58,11 @@
3258
DestinationFiles="$(MSBuildProjectDirectory)\wwwroot\framework\blazor.web.%(BlazorAssetPackage.Identity).js"
3359
SkipUnchangedFiles="true"
3460
OverwriteReadOnlyFiles="true" />
61+
62+
<!-- The dashboard is a net8.0 app that rolls forward to newer shared frameworks.
63+
Some newer blazor.web.js assets include event fields that older compatible
64+
runtimes reject as unknown JSON properties. -->
65+
<RemoveUnsupportedBlazorEventProperties FilePaths="@(BlazorAssetPackage->'$(MSBuildProjectDirectory)\wwwroot\framework\blazor.web.%(Identity).js')" />
3566
</Target>
3667

3768
<Target Name="CleanBlazorWebJs" AfterTargets="Clean">

src/Aspire.Dashboard/Components/Layout/MainLayout.razor.css

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,12 @@
8888
visible viewport instead of extending underneath the header at high zoom. */
8989
--mobile-header-height: 52px;
9090
--mobile-nav-menu-offset: 2px;
91+
--mobile-nav-menu-focus-padding: 4px;
9192
height: 100vh;
9293
width: 100vw;
9394
display: grid;
9495
grid-template-columns: auto 1fr;
95-
grid-template-rows: var(--mobile-header-height) auto auto 1fr;
96+
grid-template-rows: var(--mobile-header-height) minmax(0, auto) auto minmax(0, 1fr);
9697
grid-template-areas:
9798
"icon head"
9899
"nav-menu nav-menu"

src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<FluentMenu Class="aspire-menu-container" Open="@IsNavMenuOpen" Anchored="false" Style="grid-area: nav-menu; max-height: calc(100dvh - var(--mobile-header-height) - var(--mobile-nav-menu-offset)); margin-top: var(--mobile-nav-menu-offset); overflow-y: auto;">
1+
<FluentMenu Id="@MobileNavMenuId" Class="aspire-menu-container mobile-nav-menu" Open="@IsNavMenuOpen" Anchored="false" Style="grid-area: nav-menu; max-height: calc(100dvh - var(--mobile-header-height) - var(--mobile-nav-menu-offset)); margin-top: var(--mobile-nav-menu-offset); overflow-y: auto; padding-block: var(--mobile-nav-menu-focus-padding); scroll-padding-block: var(--mobile-nav-menu-focus-padding);">
22
@foreach (var item in GetMobileNavMenuEntries())
33
{
44
var isActive = item.LinkMatchRegex is not null && item.LinkMatchRegex.IsMatch($"/{NavigationManager.ToBaseRelativePath(NavigationManager.Uri)}");

src/Aspire.Dashboard/Components/Layout/MobileNavMenu.razor.cs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@
1111

1212
namespace Aspire.Dashboard.Components.Layout;
1313

14-
public partial class MobileNavMenu : ComponentBase
14+
public partial class MobileNavMenu : ComponentBase, IAsyncDisposable
1515
{
16+
internal const string MobileNavMenuId = "dashboard-mobile-nav-menu";
17+
18+
private IJSObjectReference? _keyboardNavigation;
19+
private DotNetObjectReference<MobileNavMenu>? _mobileNavMenuReference;
20+
private bool _keyboardNavigationInitializing;
21+
private bool _disposed;
22+
1623
[Inject]
1724
public required NavigationManager NavigationManager { get; init; }
1825

@@ -34,6 +41,80 @@ private Task NavigateToAsync(string url)
3441
return Task.CompletedTask;
3542
}
3643

44+
protected override async Task OnAfterRenderAsync(bool firstRender)
45+
{
46+
if (!_disposed && IsNavMenuOpen && _keyboardNavigation is null && !_keyboardNavigationInitializing)
47+
{
48+
_keyboardNavigationInitializing = true;
49+
try
50+
{
51+
_mobileNavMenuReference ??= DotNetObjectReference.Create(this);
52+
var keyboardNavigation = await JS.InvokeAsync<IJSObjectReference>("initializeMobileNavMenuKeyboardNavigation", _mobileNavMenuReference, MobileNavMenuId);
53+
if (_disposed || !IsNavMenuOpen)
54+
{
55+
await DisposeKeyboardNavigationAsync(keyboardNavigation);
56+
}
57+
else
58+
{
59+
_keyboardNavigation = keyboardNavigation;
60+
}
61+
}
62+
finally
63+
{
64+
_keyboardNavigationInitializing = false;
65+
}
66+
}
67+
else if (!IsNavMenuOpen && _keyboardNavigation is not null)
68+
{
69+
await DisposeKeyboardNavigationAsync();
70+
}
71+
}
72+
73+
public async ValueTask DisposeAsync()
74+
{
75+
_disposed = true;
76+
await DisposeKeyboardNavigationAsync();
77+
_mobileNavMenuReference?.Dispose();
78+
}
79+
80+
[JSInvokable]
81+
public async Task CloseMobileNavMenuFromKeyboardAsync()
82+
{
83+
CloseNavMenu();
84+
await JS.InvokeVoidAsync("focusElement", MainLayout.NavigationButtonId);
85+
}
86+
87+
[JSInvokable]
88+
public Task CloseMobileNavMenuFromFocusLossAsync()
89+
{
90+
CloseNavMenu();
91+
return Task.CompletedTask;
92+
}
93+
94+
private async ValueTask DisposeKeyboardNavigationAsync()
95+
{
96+
if (_keyboardNavigation is { } keyboardNavigation)
97+
{
98+
_keyboardNavigation = null;
99+
await DisposeKeyboardNavigationAsync(keyboardNavigation);
100+
}
101+
}
102+
103+
private async ValueTask DisposeKeyboardNavigationAsync(IJSObjectReference keyboardNavigation)
104+
{
105+
try
106+
{
107+
await JS.InvokeVoidAsync("disposeMobileNavMenuKeyboardNavigation", keyboardNavigation);
108+
}
109+
catch (JSDisconnectedException)
110+
{
111+
// The Blazor circuit can disconnect while the layout is being disposed.
112+
// In that case the browser listener is already gone with the page.
113+
}
114+
115+
await JSInteropHelpers.SafeDisposeAsync(keyboardNavigation);
116+
}
117+
37118
private IEnumerable<MobileNavMenuEntry> GetMobileNavMenuEntries()
38119
{
39120
if (DashboardClient.IsEnabled)

src/Aspire.Dashboard/wwwroot/css/app.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -946,6 +946,10 @@ fluent-tooltip[anchor="dialog_close"] > div {
946946
text-overflow: ellipsis;
947947
}
948948

949+
.aspire-menu-container.mobile-nav-menu fluent-menu-item {
950+
scroll-margin-block: var(--mobile-nav-menu-focus-padding);
951+
}
952+
949953
.aspire-menu-container fluent-menu-item.mobile-nav-menu-item-active {
950954
/* Mirror the desktop FluentAppBar selected treatment with an accent bar on
951955
the leading edge. position: relative anchors the ::before bar to the

src/Aspire.Dashboard/wwwroot/js/app.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,11 +334,45 @@ window.focusElement = function (selector, suppressFocusVisible) {
334334
}
335335
};
336336

337+
window.initializeMobileNavMenuKeyboardNavigation = function (dotnetHelper, menuId) {
338+
const menu = document.getElementById(menuId);
339+
340+
const keydownListener = function (event) {
341+
if (event.key === "Escape") {
342+
event.preventDefault();
343+
dotnetHelper.invokeMethodAsync("CloseMobileNavMenuFromKeyboardAsync");
344+
}
345+
};
346+
347+
const focusoutListener = function (event) {
348+
if (!menu.contains(event.relatedTarget)) {
349+
dotnetHelper.invokeMethodAsync("CloseMobileNavMenuFromFocusLossAsync");
350+
}
351+
};
352+
353+
// Keep Escape-to-close available as soon as the menu opens, including while
354+
// focus is still on the navigation button that opened this inline menu.
355+
// Do not trap Tab: focusout closes the menu after focus naturally leaves it.
356+
document.addEventListener("keydown", keydownListener, true);
357+
menu?.addEventListener("focusout", focusoutListener);
358+
359+
return {
360+
keydownListener,
361+
focusoutListener,
362+
menu
363+
};
364+
};
365+
366+
window.disposeMobileNavMenuKeyboardNavigation = function (obj) {
367+
document.removeEventListener("keydown", obj.keydownListener, true);
368+
obj.menu?.removeEventListener("focusout", obj.focusoutListener);
369+
};
370+
337371
window.getWindowDimensions = function() {
338372
return {
339373
width: window.innerWidth,
340374
height: window.innerHeight
341-
}
375+
};
342376
}
343377

344378
window.listenToWindowResize = function(dotnetHelper) {

tests/Aspire.Dashboard.Components.Tests/Layout/MainLayoutTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@ private void SetupMainLayoutServices(
495495

496496
JSInterop.SetupModule("window.registerGlobalKeydownListener", _ => true);
497497
JSInterop.SetupModule("window.registerOpenTextVisualizerOnClick", _ => true);
498+
LayoutSetupHelpers.SetupMobileNavMenuKeyboardNavigation(this);
498499

499500
JSInterop.Setup<BrowserInfo>("window.getBrowserInfo").SetResult(new BrowserInfo { TimeZone = "abc", UserAgent = "mozilla" });
500501
}

tests/Aspire.Dashboard.Components.Tests/Layout/MobileNavMenuTests.cs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Bunit;
99
using Microsoft.AspNetCore.Components;
1010
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.JSInterop;
1112
using Xunit;
1213

1314
namespace Aspire.Dashboard.Components.Tests.Layout;
@@ -50,25 +51,68 @@ public void MobileNavMenu_ConstrainedToRemainingViewport()
5051
Assert.DoesNotContain("height: 100vh", style);
5152
Assert.Contains("margin-top: var(--mobile-nav-menu-offset)", style);
5253
Assert.Contains("overflow-y: auto", style);
54+
Assert.Contains("padding-block: var(--mobile-nav-menu-focus-padding)", style);
55+
Assert.Contains("scroll-padding-block: var(--mobile-nav-menu-focus-padding)", style);
56+
Assert.Contains("mobile-nav-menu", cut.Find("fluent-menu").ClassList);
5357
}
5458

55-
private IRenderedComponent<MobileNavMenu> RenderMobileNavMenu(string currentUrl)
59+
[Fact]
60+
public void Render_OpenMenu_InitializesKeyboardNavigationWithComponentReferenceAndMenuId()
61+
{
62+
_ = RenderMobileNavMenu(DashboardUrls.ResourcesUrl());
63+
64+
var invocation = Assert.Single(JSInterop.Invocations, i => i.Identifier == "initializeMobileNavMenuKeyboardNavigation");
65+
Assert.Collection(
66+
invocation.Arguments,
67+
argument => Assert.IsAssignableFrom<DotNetObjectReference<MobileNavMenu>>(argument),
68+
argument => Assert.Equal(MobileNavMenu.MobileNavMenuId, argument));
69+
}
70+
71+
[Fact]
72+
public async Task CloseMobileNavMenuFromFocusLossAsync_ClosesMenuWithoutRestoringFocus()
73+
{
74+
var closeNavMenuCalled = false;
75+
var cut = RenderMobileNavMenu(DashboardUrls.ResourcesUrl(), () => closeNavMenuCalled = true, isNavMenuOpen: false);
76+
77+
await cut.InvokeAsync(cut.Instance.CloseMobileNavMenuFromFocusLossAsync);
78+
79+
Assert.True(closeNavMenuCalled);
80+
Assert.DoesNotContain(JSInterop.Invocations, invocation => invocation.Identifier == "focusElement");
81+
}
82+
83+
[Fact]
84+
public async Task CloseMobileNavMenuFromKeyboardAsync_ClosesMenuAndRestoresFocus()
85+
{
86+
JSInterop.SetupVoid("focusElement", _ => true).SetVoidResult();
87+
var closeNavMenuCalled = false;
88+
var cut = RenderMobileNavMenu(DashboardUrls.ResourcesUrl(), () => closeNavMenuCalled = true, isNavMenuOpen: false);
89+
90+
await cut.InvokeAsync(cut.Instance.CloseMobileNavMenuFromKeyboardAsync);
91+
92+
Assert.True(closeNavMenuCalled);
93+
var invocation = Assert.Single(JSInterop.Invocations, invocation => invocation.Identifier == "focusElement");
94+
var argument = Assert.Single(invocation.Arguments);
95+
Assert.Equal(MainLayout.NavigationButtonId, argument);
96+
}
97+
98+
private IRenderedComponent<MobileNavMenu> RenderMobileNavMenu(string currentUrl, Action? closeNavMenu = null, bool isNavMenuOpen = true)
5699
{
57100
FluentUISetupHelpers.AddCommonDashboardServices(this);
58101
Services.AddSingleton<IDashboardClient>(new TestDashboardClient(isEnabled: true));
59102
FluentUISetupHelpers.SetupFluentUIComponents(this);
60103
FluentUISetupHelpers.SetupFluentMenu(this);
61104
FluentUISetupHelpers.SetupFluentDivider(this);
62105
FluentUISetupHelpers.SetupFluentAnchoredRegion(this);
106+
LayoutSetupHelpers.SetupMobileNavMenuKeyboardNavigation(this);
63107

64108
var navigationManager = Services.GetRequiredService<NavigationManager>();
65109
navigationManager.NavigateTo(currentUrl);
66110

67111
return RenderComponent<MobileNavMenu>(builder =>
68112
{
69-
builder.Add(p => p.IsNavMenuOpen, true);
113+
builder.Add(p => p.IsNavMenuOpen, isNavMenuOpen);
70114
builder.Add(p => p.IsAIEnabled, false);
71-
builder.Add(p => p.CloseNavMenu, () => { });
115+
builder.Add(p => p.CloseNavMenu, closeNavMenu ?? (() => { }));
72116
builder.Add(p => p.LaunchHelpAsync, () => Task.CompletedTask);
73117
builder.Add(p => p.LaunchAIAgentsAsync, () => Task.CompletedTask);
74118
builder.Add(p => p.IsAgentHelpEnabled, false);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Bunit;
5+
6+
namespace Aspire.Dashboard.Components.Tests.Shared;
7+
8+
internal static class LayoutSetupHelpers
9+
{
10+
public static void SetupMobileNavMenuKeyboardNavigation(TestContext context)
11+
{
12+
context.JSInterop.SetupModule(invocation => invocation.Identifier == "initializeMobileNavMenuKeyboardNavigation");
13+
context.JSInterop.SetupVoid("disposeMobileNavMenuKeyboardNavigation", _ => true);
14+
}
15+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Xunit;
5+
6+
namespace Aspire.Dashboard.Tests;
7+
8+
public class BlazorAssetsTests
9+
{
10+
[Theory]
11+
[InlineData("10")]
12+
[InlineData("11")]
13+
public void BlazorWebJs_DoesNotSendUnsupportedKeyboardEventProperties(string runtimeMajorVersion)
14+
{
15+
var blazorWebJsPath = Path.Combine(GetRepoRoot(), "src", "Aspire.Dashboard", "wwwroot", "framework", $"blazor.web.{runtimeMajorVersion}.js");
16+
Assert.True(File.Exists(blazorWebJsPath), $"Expected generated Blazor asset at {blazorWebJsPath}");
17+
18+
var blazorWebJs = File.ReadAllText(blazorWebJsPath);
19+
20+
Assert.Contains("keydown", blazorWebJs, StringComparison.Ordinal);
21+
Assert.False(
22+
blazorWebJs.Contains("isComposing", StringComparison.Ordinal),
23+
"The dashboard Blazor script must not emit KeyboardEvent.isComposing because the server event parser rejects the unknown property.");
24+
}
25+
26+
private static string GetRepoRoot()
27+
{
28+
var directory = new DirectoryInfo(AppContext.BaseDirectory);
29+
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Aspire.slnx")))
30+
{
31+
directory = directory.Parent;
32+
}
33+
34+
Assert.NotNull(directory);
35+
return directory.FullName;
36+
}
37+
}

0 commit comments

Comments
 (0)