Name dialogs and contain native overlay keyboard focus

This commit is contained in:
Joseph Doherty
2026-11-13 17:42:14 -05:00
parent cdd3adf6fc
commit 87c882ec0a
46 changed files with 1353 additions and 119 deletions
@@ -33,6 +33,21 @@ public class AuditDrilldownDrawerTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>The drawer has modal semantics and Escape dispatches close once.</summary>
[Fact]
public void OpenDrawer_HasNamedModalAndEscapeCloses()
{
var closes = 0;
var cut = Render<AuditDrilldownDrawer>(p => p.Add(c => c.Event, MakeEvent())
.Add(c => c.IsOpen, true).Add(c => c.OnClose, () => closes++));
var dialog = cut.Find("[role=dialog]");
Assert.Equal("true", dialog.GetAttribute("aria-modal"));
var titleId = dialog.GetAttribute("aria-labelledby");
Assert.Contains("Audit Event", cut.Find("#" + titleId).TextContent);
dialog.KeyDown(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Escape" });
Assert.Equal(1, closes);
}
private static AuditEventView MakeEvent(
AuditChannel channel = AuditChannel.ApiOutbound,
AuditKind kind = AuditKind.ApiCall,
@@ -372,6 +387,8 @@ public class AuditDrilldownDrawerTests : BunitContext
{
// Set up Strict mode interop so the call must match exactly.
JSInterop.Mode = JSRuntimeMode.Strict;
JSInterop.Setup<bool?>("sbDialog.activate", _ => true).SetResult(true);
JSInterop.SetupVoid("sbDialog.deactivate", _ => true).SetVoidResult();
var clipboardCall = JSInterop.SetupVoid(
"navigator.clipboard.writeText",
invocation => invocation.Arguments.Count == 1
@@ -33,6 +33,46 @@ public class DialogHostShowAsyncTests : BunitContext
JSInterop.Mode = JSRuntimeMode.Loose;
}
/// <summary>Unsupported overlapping presentation cancels safely; disposal resolves pending callers.</summary>
[Fact]
public async Task RejectedOverlayAndDisposal_ResolvePendingResults()
{
JSInterop.Setup<bool?>("sbDialog.activate", _ => true).SetResult(false);
var cut = Render<DialogHost>();
Task<bool> pending = null!;
await cut.InvokeAsync(() => { pending = _service.ConfirmAsync("Rejected", "Local only"); });
cut.WaitForAssertion(() => Assert.Null(_service.Current));
Assert.False(await pending);
JSInterop.Setup<bool?>("sbDialog.activate", _ => true).SetResult(true);
await cut.InvokeAsync(() => { pending = _service.ConfirmAsync("Open", "Local only"); });
await cut.InvokeAsync(async () => await cut.Instance.DisposeAsync());
Assert.False(await pending);
Assert.Null(_service.Current);
}
/// <summary>Names successive dialogs and connects prompt labels without stale descriptions.</summary>
[Fact]
public async Task Dialog_NamesAndPromptAssociationsFollowCurrentDialog()
{
var cut = Render<DialogHost>();
await cut.InvokeAsync(() => { _ = _service.ConfirmAsync("First title", "First body"); });
var first = cut.Find("[role=dialog]");
var titleId = first.GetAttribute("aria-labelledby");
Assert.False(string.IsNullOrWhiteSpace(titleId));
Assert.Equal("First title", cut.Find("#" + titleId).TextContent);
var descriptionId = first.GetAttribute("aria-describedby");
Assert.Equal("First body", cut.Find("#" + descriptionId).TextContent);
await cut.InvokeAsync(() => cut.Find("button.btn-close").Click());
await cut.InvokeAsync(() => { _ = _service.PromptAsync("Second title", "Enter a value"); });
var prompt = cut.Find("[role=dialog]");
Assert.Equal("Second title", cut.Find("#" + prompt.GetAttribute("aria-labelledby")).TextContent);
var input = cut.Find("input");
Assert.Equal(input.Id, cut.Find("label").GetAttribute("for"));
Assert.False(string.IsNullOrWhiteSpace(input.Id));
Assert.DoesNotContain("First body", cut.Markup);
await cut.InvokeAsync(() => cut.Find("button.btn-close").Click());
}
/// <summary>Verifies that ShowAsync renders a custom body and resolves the typed result.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]