diff --git a/ShellUI.Tests/PackageBehaviorTests.cs b/ShellUI.Tests/PackageBehaviorTests.cs index f7427d6..4e92919 100644 --- a/ShellUI.Tests/PackageBehaviorTests.cs +++ b/ShellUI.Tests/PackageBehaviorTests.cs @@ -63,6 +63,59 @@ public void Dropdown_ClosesOnEscapeButNotOnOtherKeys() Assert.Equal(new[] { false }, changes); } + [Fact] + public void DialogContent_TrapsFocusWhileOpenAndReleasesItOnClose() + { + using var ctx = new BunitContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + var dialog = ctx.Render(p => p + .Add(c => c.Open, true) + .AddChildContent(c => c.AddChildContent("Body"))); + + var trap = ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus"); + ctx.JSInterop.VerifyNotInvoke("ShellUI.releaseFocus"); + + dialog.Render(p => p.Add(c => c.Open, false)); + + var release = ctx.JSInterop.VerifyInvoke("ShellUI.releaseFocus"); + Assert.Equal(trap.Arguments[0], release.Arguments[0]); + } + + [Fact] + public void SheetContent_TrapsFocusWhileOpen() + { + using var ctx = new BunitContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + + ctx.Render(p => p + .Add(c => c.Open, true) + .AddChildContent(c => c.AddChildContent("Body"))); + + ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus"); + } + + [Fact] + public void LegacySheet_TrapsFocusAndReleasesItWhenEscapeCloses() + { + using var ctx = new BunitContext(); + ctx.JSInterop.Mode = JSRuntimeMode.Loose; + bool? open = null; + + var sheet = ctx.Render(p => p + .Add(c => c.Open, true) + .Add(c => c.Title, "Settings") + .Add(c => c.OpenChanged, EventCallback.Factory.Create(this, v => open = v)) + .AddChildContent("Body")); + + ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus"); + + sheet.Find("[role=dialog]").KeyDown(new KeyboardEventArgs { Key = "Escape" }); + + Assert.False(open); + ctx.JSInterop.VerifyInvoke("ShellUI.releaseFocus"); + } + [Fact] public void TimePicker_ShowsASetValue() { diff --git a/src/ShellUI.Components/Components/AlertDialogContent.razor b/src/ShellUI.Components/Components/AlertDialogContent.razor index e353ef8..54b12e4 100644 --- a/src/ShellUI.Components/Components/AlertDialogContent.razor +++ b/src/ShellUI.Components/Components/AlertDialogContent.razor @@ -21,6 +21,8 @@ private bool _isLocked; private bool _focused; private ElementReference _content; + private readonly string _trapId = Guid.NewGuid().ToString("N"); + private bool _trapped; // Escape dismisses an alert dialog as Cancel; the backdrop does not. private async Task OnKeyDownAsync(KeyboardEventArgs e) @@ -34,9 +36,14 @@ if (isOpen && !_focused) { _focused = true; + try { await JS.InvokeVoidAsync("ShellUI.trapFocus", _trapId, _content); _trapped = true; } catch { } try { await _content.FocusAsync(); } catch (InvalidOperationException) { } } - else if (!isOpen) _focused = false; + else if (!isOpen) + { + _focused = false; + await ReleaseFocusAsync(); + } var shouldBeLocked = Parent?.IsOpen == true; if (shouldBeLocked && !_isLocked) @@ -49,8 +56,16 @@ } } + private async Task ReleaseFocusAsync() + { + if (!_trapped) return; + _trapped = false; + try { await JS.InvokeVoidAsync("ShellUI.releaseFocus", _trapId); } catch { } + } + public async ValueTask DisposeAsync() { + await ReleaseFocusAsync(); if (_isLocked) { try { await JS.InvokeVoidAsync("ShellUI.unlockBodyScroll"); } catch { } diff --git a/src/ShellUI.Components/Components/DialogContent.razor b/src/ShellUI.Components/Components/DialogContent.razor index c2fa0e9..d17f03e 100644 --- a/src/ShellUI.Components/Components/DialogContent.razor +++ b/src/ShellUI.Components/Components/DialogContent.razor @@ -25,6 +25,8 @@ private bool _isLocked; private bool _focused; private ElementReference _content; + private readonly string _trapId = Guid.NewGuid().ToString("N"); + private bool _trapped; private async Task Close() { @@ -45,9 +47,14 @@ if (isOpen && !_focused) { _focused = true; + try { await JS.InvokeVoidAsync("ShellUI.trapFocus", _trapId, _content); _trapped = true; } catch { } try { await _content.FocusAsync(); } catch (InvalidOperationException) { } } - else if (!isOpen) _focused = false; + else if (!isOpen) + { + _focused = false; + await ReleaseFocusAsync(); + } var shouldBeLocked = Dialog?.Open == true; if (shouldBeLocked && !_isLocked) @@ -60,8 +67,16 @@ } } + private async Task ReleaseFocusAsync() + { + if (!_trapped) return; + _trapped = false; + try { await JS.InvokeVoidAsync("ShellUI.releaseFocus", _trapId); } catch { } + } + public async ValueTask DisposeAsync() { + await ReleaseFocusAsync(); if (_isLocked) { try { await JS.InvokeVoidAsync("ShellUI.unlockBodyScroll"); } catch { } diff --git a/src/ShellUI.Components/Components/Drawer.razor b/src/ShellUI.Components/Components/Drawer.razor index 76bd72d..6c01d76 100644 --- a/src/ShellUI.Components/Components/Drawer.razor +++ b/src/ShellUI.Components/Components/Drawer.razor @@ -1,4 +1,7 @@ @namespace ShellUI.Components +@using Microsoft.JSInterop +@implements IAsyncDisposable +@inject IJSRuntime JS @if (UseCompositional) { @@ -9,7 +12,7 @@ else if (Open) {
-
+