feat(accessibility): add native keyboard dialogs and semantic account links (U08)

This commit is contained in:
2026-11-02 19:24:21 +01:00
parent 86a73b862a
commit 62e2b7dee8
23 changed files with 218 additions and 65 deletions
+4
View File
@@ -59,3 +59,7 @@ Prüfung: 42/42 Debug-Tests, Desktop-Build ohne Warnungen. Computer Use in separ
## Weiterer Umsetzungsplan
U08: gemeinsame native Modaldialoge mit Fokus/Escape/Verlustschutz, semantische Kontolinks. U09: lesbare Zeilenaktionen mit größeren Treffern. U10: Löschstatus, Bestätigung beider Umbuchungsseiten und gezieltes Rückgängig. U11: Richtung und Auswirkungen von beiden Seiten identisch erklären. U12: bestehende gemeinsame Anfangsbestands-Semantik beibehalten und Korrektur vorab erklären/bestätigen. Danach U13: skalierbare lesbare Darstellung/Kontrast; U14: lokale Hilfe und überspringbare Einführung; U15: Suche und Sortierung mit getrennten Treffermengen; U16: lokale Darstellungsressourcen und sichere Fehlerauswege. Nach jeder Maßnahme bauen und testen; Paketgrenzen pushen und PR aktualisieren.
## U08 – Tastatur und Modaldialoge
Gemeinsame native dialog-Komponente für Erfassung, Bestätigungen, Konten, Namen, Übertrag, Jahr und Über. showModal sperrt den Hintergrund und hält den Tastaturfokus im Dialog; Fokus geht ins sinnvolle Feld und nach Schließen zum Auslöser zurück. Escape schließt unveränderte Dialoge; geänderte Eingaben bieten Weiterbearbeiten/Verwerfen. Während Speichern kein Schließen. Unsichere Bestätigungen fokussieren Abbrechen und verwenden keine implizite Formularbestätigung. Kontokarten sind semantische Links. Labels sind technisch verbunden. Toasts bleiben auch innerhalb nativer Modaldialoge zugänglich. Prüfung: 44/44 Debug-Tests, warnungsfreier Build; Computer Use beobachtete Fokus im Beschreibungsfeld, ausschließlich modalen Accessibility-Baum und Escape-Schließen. Kein vollständiger Screenreader-Nutzertest.
@@ -1,11 +1,11 @@
@inject NavigationManager Navigation
<div class="card account-card h-100" @onclick="Navigate">
<a class="card account-card h-100" href="@($"/accounts/{Account.Id}")" @onclick="Navigate" @onclick:preventDefault="true">
<div class="card-body d-flex flex-column justify-content-between">
<h3 class="card-title mb-3 mt-0">@Account.Name</h3>
<div class="@(Account.TotalBalance >= 0 ? "amount-positive" : "amount-negative") fs-4">
@($"{Account.TotalBalance:N2} €")
</div>
</div>
</div>
</a>
@@ -1,15 +1,16 @@
<div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true">
<ModalDialog @ref="modal" Title="Neues Konto" OnCancel="OnCancel" Dirty="@(!string.IsNullOrWhiteSpace(name))">
<h5 class="mb-3">Neues Konto</h5>
<form @onsubmit="Save" @onsubmit:preventDefault="true">
<div class="form-container">
<label class="form-label">Kontoname</label>
<input type="text" class="form-control" @bind="name" @bind:event="oninput"
<label class="form-label" for="account-name">Kontoname</label>
<input id="account-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput"
placeholder="z.B. Girokonto" />
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Erstellen</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</div>
<button type="submit" class="btn btn-success" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Erstellen</button>
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</form>
</ModalDialog>
@code { private ModalDialog? modal; }
@@ -30,7 +30,7 @@ public partial class AddAccountDialog
}
}
private async Task Cancel() => await OnCancel.InvokeAsync();
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
#endregion
}
@@ -1,8 +1,7 @@
@inject ToastService Toasts
@inject IEntryService EntryService
<div class="dialog-backdrop">
<div class="dialog-content booking-dialog" role="dialog" aria-modal="true" aria-labelledby="entry-dialog-title">
<ModalDialog @ref="modal" Title="Einnahme oder Ausgabe" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && IsDirty)">
<h5 id="entry-dialog-title">@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName</h5>
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
@@ -31,7 +30,7 @@
<label class="form-label" for="entry-title">Beschreibung</label>
<div>
<input id="entry-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="z. B. Mitgliedsbeitrag oder Porto" aria-describedby="entry-title-help" aria-invalid="@(string.IsNullOrWhiteSpace(title) ? "true" : "false")" />
<input id="entry-title" data-initial-focus type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="z. B. Mitgliedsbeitrag oder Porto" aria-describedby="entry-title-help" aria-invalid="@(string.IsNullOrWhiteSpace(title) ? "true" : "false")" />
@if (string.IsNullOrWhiteSpace(title)) { <p id="entry-title-help" class="text-danger">Bitte beschreiben, wofür das Geld eingenommen oder ausgegeben wurde.</p> }
</div>
@@ -58,5 +57,6 @@
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</form>
</div>
</div>
</ModalDialog>
@code { private ModalDialog? modal; }
@@ -45,6 +45,9 @@ public partial class AddEntryDialog
private string amountText = string.Empty;
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
private string initialState = string.Empty;
private string CurrentState => $"{dateText}|{title}|{amountText}|{entryType}";
private bool IsDirty => CurrentState != initialState;
private bool initialized;
private int? initializedEntryId;
@@ -64,6 +67,7 @@ public partial class AddEntryDialog
title = EditEntry.Title;
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
}
initialState = CurrentState;
}
#endregion
@@ -106,7 +110,7 @@ public partial class AddEntryDialog
private async Task Cancel()
{
if (!isSaving) await OnCancel.InvokeAsync();
if (!isSaving && modal != null) await modal.RequestCloseAsync();
}
#endregion
@@ -2,8 +2,7 @@
@inject IEntryService EntryService
@inject IAccountService AccountService
<div class="dialog-backdrop">
<div class="dialog-content booking-dialog" @onclick:stopPropagation="true">
<ModalDialog @ref="modal" Title="Geld zwischen Konten verschieben" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && IsDirty)">
<h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5>
<p class="text-muted">Erfasst nur die Buchung. Führt keine Banküberweisung aus.</p>
@@ -11,7 +10,7 @@
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
<fieldset disabled="@(isSaving || committedResult != null || completed)">
<div class="form-container">
<label class="form-label">Zielkonto</label>
<label class="form-label" for="transfer-account">Zielkonto</label>
<select id="transfer-account" class="form-select" @bind="targetAccountId">
<option value="0">— Auswählen —</option>
@foreach (var acc in accounts.Where(a => a.Id != SourceAccountId))
@@ -20,13 +19,13 @@
}
</select>
<label class="form-label">Datum</label>
<label class="form-label" for="transfer-date">Datum</label>
<input id="transfer-date" type="date" class="form-control" value="@dateText" @oninput="e => dateText = e.Value?.ToString() ?? string.Empty" />
<label class="form-label">Bezeichnung</label>
<label class="form-label" for="transfer-title">Bezeichnung</label>
<input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
<label class="form-label">Betrag (€)</label>
<label class="form-label" for="transfer-amount">Betrag (€)</label>
<div><AmountInput Id="transfer-amount" @bind-Value="amountText" />
<p class="text-muted">Betrag ohne Minuszeichen.</p></div>
</div>
@@ -47,6 +46,6 @@
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</form>
</div>
</div>
</ModalDialog>
@code { private ModalDialog? modal; }
@@ -42,6 +42,9 @@ public partial class AddTransferDialog
private string amountText = string.Empty;
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
private decimal amount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out var value) ? value : 0;
private string initialState = string.Empty;
private string CurrentState => $"{dateText}|{title}|{amountText}|{targetAccountId}";
private bool IsDirty => CurrentState != initialState;
private bool initialized;
private int? initializedEntryId;
@@ -72,6 +75,7 @@ public partial class AddTransferDialog
title = EditEntry.Title;
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
}
initialState = CurrentState;
}
#endregion
@@ -114,7 +118,7 @@ public partial class AddTransferDialog
private async Task Cancel()
{
if (!isSaving) await OnCancel.InvokeAsync();
if (!isSaving && modal != null) await modal.RequestCloseAsync();
}
#endregion
@@ -1,11 +1,10 @@
<div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true">
<ModalDialog @ref="modal" Title="@Title" OnCancel="OnCancel" >
<h5>@Title</h5>
<p>@Message</p>
<div class="d-flex justify-content-end gap-2">
<button class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
</div>
</div>
<button type="button" class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
<button type="button" class="btn btn-outline-secondary" data-safe-focus @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
</div>
</ModalDialog>
@code { private ModalDialog? modal; }
@@ -36,7 +36,7 @@ public partial class ConfirmDialog
private async Task Confirm() => await OnConfirm.InvokeAsync();
private async Task Cancel() => await OnCancel.InvokeAsync();
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
#endregion
}
@@ -1,14 +1,13 @@
<div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true">
<ModalDialog @ref="modal" Title="Bestand korrigieren" OnCancel="OnCancel" Dirty="@(amount != CurrentAmount)">
<h5 class="mb-3">Übertrag bearbeiten</h5>
<div class="form-container">
<label class="form-label">Übertrag (€)</label>
<input type="number" class="form-control no-spinner" step="0.01" @bind="amount" />
<label class="form-label" for="carryover">Übertrag (€)</label>
<input id="carryover" data-initial-focus type="number" class="form-control no-spinner" step="0.01" @bind="amount" />
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save"><i class="bi bi-check-lg"></i> Speichern</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</div>
<button type="button" class="btn btn-success" @onclick="Save"><i class="bi bi-check-lg"></i> Speichern</button>
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</ModalDialog>
@code { private ModalDialog? modal; }
@@ -33,7 +33,7 @@ public partial class EditCarryoverDialog
private async Task Save() => await OnSave.InvokeAsync(amount);
private async Task Cancel() => await OnCancel.InvokeAsync();
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
#endregion
}
@@ -1,14 +1,15 @@
<div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true">
<ModalDialog @ref="modal" Title="Name bearbeiten" OnCancel="OnCancel" Dirty="@(name != CurrentName)">
<h5 class="mb-3">@DialogTitle</h5>
<form @onsubmit="Save" @onsubmit:preventDefault="true">
<div class="form-container">
<label class="form-label">@NameLabel</label>
<input type="text" class="form-control" @bind="name" @bind:event="oninput" />
<label class="form-label" for="edit-name">@NameLabel</label>
<input id="edit-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput" />
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-success" @onclick="Save" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Speichern</button>
<button class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</div>
<button type="submit" class="btn btn-success" disabled="@string.IsNullOrWhiteSpace(name)"><i class="bi bi-check-lg"></i> Speichern</button>
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
</div>
</form>
</ModalDialog>
@code { private ModalDialog? modal; }
@@ -31,7 +31,8 @@ public partial class EditNameDialog
#region Lifecycle
protected override void OnParametersSet() => name = CurrentName;
private bool initialized;
protected override void OnParametersSet() { if (!initialized) { name = CurrentName; initialized = true; } }
#endregion
@@ -45,7 +46,7 @@ public partial class EditNameDialog
}
}
private async Task Cancel() => await OnCancel.InvokeAsync();
private async Task Cancel() { if (modal != null) await modal.RequestCloseAsync(); else await OnCancel.InvokeAsync(); }
#endregion
}
@@ -0,0 +1,76 @@
@implements IAsyncDisposable
@inject IJSRuntime JS
<dialog @ref="element" class="dialog-content app-modal" aria-labelledby="@titleId">
<ToastHost InDialog="true" />
<h2 id="@titleId" class="visually-hidden">@Title</h2>
<div inert="@discardPrompt">@ChildContent</div>
@if (discardPrompt)
{
<section role="alertdialog" aria-label="Ungespeicherte Eingaben">
<h3>Ungespeicherte Eingaben</h3>
<p>Die Änderungen wurden noch nicht gespeichert. Möchtest du weiter bearbeiten oder die Eingaben verwerfen?</p>
<div class="dialog-actions">
<button type="button" class="btn btn-primary" data-discard-continue @onclick="ContinueEditing">Weiter bearbeiten</button>
<button type="button" class="btn btn-outline-danger" @onclick="Discard">Eingaben verwerfen</button>
</div>
</section>
}
</dialog>
@code {
[Parameter] public string Title { get; set; } = "Dialog";
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] public bool Dirty { get; set; }
[Parameter] public bool Busy { get; set; }
private readonly string titleId = "dialog-" + Guid.NewGuid().ToString("N");
private ElementReference element;
private DotNetObjectReference<ModalDialog>? reference;
private bool discardPrompt;
private bool focusPrompt;
private bool disposed;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
reference = DotNetObjectReference.Create(this);
await JS.InvokeVoidAsync("duempelkasDialog.open", element, reference);
}
if (focusPrompt)
{
focusPrompt = false;
await JS.InvokeVoidAsync("duempelkasDialog.focus", element, discardPrompt ? "[data-discard-continue]" : "[data-initial-focus],input,select,textarea,button");
}
}
[JSInvokable]
public async Task RequestCloseAsync()
{
if (Busy || disposed) return;
if (Dirty && !discardPrompt)
{
discardPrompt = true;
focusPrompt = true;
await InvokeAsync(StateHasChanged);
}
else if (discardPrompt) await ContinueEditing();
else await OnCancel.InvokeAsync();
}
private Task ContinueEditing()
{
discardPrompt = false;
focusPrompt = true;
return Task.CompletedTask;
}
private Task Discard() => OnCancel.InvokeAsync();
public async ValueTask DisposeAsync()
{
disposed = true;
try { await JS.InvokeVoidAsync("duempelkasDialog.close", element); }
catch (Exception ex) when (ex is JSException or JSDisconnectedException or ObjectDisposedException or TaskCanceledException) { }
reference?.Dispose();
}
}
@@ -20,8 +20,7 @@
@if (showAboutDialog)
{
<div class="dialog-backdrop">
<div class="dialog-content" @onclick:stopPropagation="true">
<ModalDialog Title="Über Dümpelkas" OnCancel="CloseAboutDialog">
<h5>Über Dümpelkas &middot; Version @AppVersion</h5>
<p class="mb-2">Entwickler: Andre Beging</p>
<p class="mb-3">E-Mail: <a href="mailto:mail@beging.de" style="color: var(--color-text);">mail@beging.de</a></p>
@@ -30,7 +29,6 @@
<i class="bi bi-x-lg"></i> Schließen
</button>
</div>
</div>
</div>
</ModalDialog>
}
@@ -1,7 +1,7 @@
@implements IDisposable
@inject ToastService Toasts
<div class="toast-stack" aria-label="Meldungen">
<div class="toast-stack @(InDialog ? "modal-toasts" : "")" aria-label="Meldungen">
@foreach (var toast in Toasts.Messages)
{
<section @key="toast.Id" class="app-toast toast-@toast.Kind.ToString().ToLowerInvariant()"
@@ -23,6 +23,7 @@
</div>
@code {
[Parameter] public bool InDialog { get; set; }
private readonly HashSet<Guid> running = [];
protected override void OnInitialized() => Toasts.Changed += Refresh;
private void Refresh() => _ = InvokeAsync(StateHasChanged);
@@ -184,8 +184,7 @@
@if (showYearFilterDialog)
{
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
<div class="dialog-content" @onclick:stopPropagation="true">
<ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
<h5>Filter auswählen</h5>
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
<div class="filter-options-grid">
@@ -204,7 +203,6 @@
<i class="bi bi-x-lg"></i> Schließen
</button>
</div>
</div>
</div>
</ModalDialog>
}
+2 -4
View File
@@ -116,8 +116,7 @@
@if (showYearFilterDialog)
{
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
<div class="dialog-content" @onclick:stopPropagation="true">
<ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
<h5>Filter auswählen</h5>
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
<div class="filter-options-grid">
@@ -136,7 +135,6 @@
<i class="bi bi-x-lg"></i> Schließen
</button>
</div>
</div>
</div>
</ModalDialog>
}
@@ -630,3 +630,14 @@ fieldset { min-width: 0; }
@keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } }
.input-rejection:empty { display: none; }
.app-modal { color: var(--color-text); max-height: calc(100dvh - 2rem); overflow-y: auto; }
.app-modal::backdrop { background: #0009; backdrop-filter: blur(3px); }
.dialog-actions { display: flex; gap: .5rem; flex-wrap: wrap; justify-content: flex-end; }
.account-card { display: block; width: 100%; color: inherit; text-decoration: none; }
:focus-visible { outline: 3px solid var(--color-accent); outline-offset: 3px; }
fieldset { border: 0; margin: 0; padding: 0; }
body:has(dialog[open]) .toast-stack:not(.modal-toasts) { display: none; }
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; }
+13
View File
@@ -17,3 +17,16 @@
if (e.target.matches('[data-amount-guard]') && !accept(e.target, e.clipboardData.getData('text').trim())) e.preventDefault();
});
})();
window.duempelkasDialog = {
open(dialog, receiver) {
const opener = document.activeElement;
dialog.addEventListener('cancel', event => { event.preventDefault(); receiver.invokeMethodAsync('RequestCloseAsync'); });
dialog.addEventListener('close', () => { if (opener?.isConnected) opener.focus(); });
dialog.showModal();
const initial = dialog.querySelector('[data-safe-focus]') || dialog.querySelector('[data-initial-focus]') || dialog.querySelector('input,select,textarea,button');
initial?.focus();
},
close(dialog) { if (dialog?.open) dialog.close(); },
focus(dialog, selector) { dialog.querySelector(selector)?.focus(); }
};
@@ -15,6 +15,7 @@ public class BookingFormTests
public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
{
using var context = new BunitContext();
context.JSInterop.Mode = JSRuntimeMode.Loose;
context.Services.AddSingleton<ToastService>();
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.AccountName, "Barkasse"));
@@ -40,6 +41,7 @@ public class BookingFormTests
public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput()
{
using var context = new BunitContext();
context.JSInterop.Mode = JSRuntimeMode.Loose;
context.Services.AddSingleton<ToastService>();
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, new(2026, 1, 1), "Porto", 4.20m, false, false, null, null, null);
@@ -0,0 +1,44 @@
using Bunit;
using Duempelkas.App.Components.Dialogs;
using Duempelkas.App.Services;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Duempelkas.Tests;
public class ModalDialogTests
{
[Fact]
public async Task EscapeRequest_ClosesCleanDialog_ButProtectsDirtyInput()
{
using var context = new BunitContext();
context.JSInterop.Mode = JSRuntimeMode.Loose;
context.Services.AddSingleton<ToastService>();
var cancelled = 0;
var modal = context.Render<ModalDialog>(p => p.Add(x => x.Title, "Testdialog").Add(x => x.OnCancel, () => cancelled++));
Assert.NotNull(modal.Find("dialog").GetAttribute("aria-labelledby"));
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
Assert.Equal(1, cancelled);
modal.Render(p => p.Add(x => x.Dirty, true));
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
Assert.Equal(1, cancelled);
Assert.Contains("Ungespeicherte Eingaben", modal.Markup);
modal.Find("[data-discard-continue]").Click();
Assert.Empty(modal.FindAll("[role=alertdialog]"));
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
modal.Find("button.btn-outline-danger").Click();
Assert.Equal(2, cancelled);
}
[Fact]
public async Task BusyDialog_CannotClose()
{
using var context = new BunitContext();
context.JSInterop.Mode = JSRuntimeMode.Loose;
context.Services.AddSingleton<ToastService>();
var cancelled = false;
var modal = context.Render<ModalDialog>(p => p.Add(x => x.Busy, true).Add(x => x.OnCancel, () => cancelled = true));
await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync());
Assert.False(cancelled);
}
}