diff --git a/docs/usability-umsetzung.md b/docs/usability-umsetzung.md index 2f4c72d..5df1311 100644 --- a/docs/usability-umsetzung.md +++ b/docs/usability-umsetzung.md @@ -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. diff --git a/src/Duempelkas.App/Components/Accounts/AccountCard.razor b/src/Duempelkas.App/Components/Accounts/AccountCard.razor index e11d803..3f325c4 100644 --- a/src/Duempelkas.App/Components/Accounts/AccountCard.razor +++ b/src/Duempelkas.App/Components/Accounts/AccountCard.razor @@ -1,11 +1,11 @@ @inject NavigationManager Navigation -
+

@Account.Name

@($"{Account.TotalBalance:N2} €")
-
+ diff --git a/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor index 86758ae..c315791 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor @@ -1,15 +1,16 @@ -
-
+
Neues Konto
+
- - Kontoname +
- - + +
-
-
+ + +@code { private ModalDialog? modal; } diff --git a/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor.cs index 1ea57cd..815724d 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/AddAccountDialog.razor.cs @@ -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 } diff --git a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor index 8823f24..afede33 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor @@ -1,8 +1,7 @@ @inject ToastService Toasts @inject IEntryService EntryService -
- -
- + + +@code { private ModalDialog? modal; } diff --git a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs index 69746fd..b4fc99d 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/AddEntryDialog.razor.cs @@ -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 diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor index 6c88c34..27ba6cb 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor @@ -2,8 +2,7 @@ @inject IEntryService EntryService @inject IAccountService AccountService -
-
+
@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")

Erfasst nur die Buchung. Führt keine Banküberweisung aus.

@@ -11,7 +10,7 @@
- + - + - + - +

Betrag ohne Minuszeichen.

@@ -47,6 +46,6 @@
-
- + +@code { private ModalDialog? modal; } diff --git a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs index 5ddbc85..d6e75f0 100644 --- a/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/AddTransferDialog.razor.cs @@ -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 diff --git a/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor b/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor index 38cfe92..3feb27d 100644 --- a/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor @@ -1,11 +1,10 @@ -
-
+
@Title

@Message

- - + +
-
-
+ +@code { private ModalDialog? modal; } diff --git a/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor.cs index 0516e86..cfa5f18 100644 --- a/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/ConfirmDialog.razor.cs @@ -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 } diff --git a/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor b/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor index f74e34c..294f1dd 100644 --- a/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor @@ -1,14 +1,13 @@ -
-
+
Übertrag bearbeiten
- - + +
- - + +
-
-
+ +@code { private ModalDialog? modal; } diff --git a/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor.cs index 9ba813a..7f97158 100644 --- a/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/EditCarryoverDialog.razor.cs @@ -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 } diff --git a/src/Duempelkas.App/Components/Dialogs/EditNameDialog.razor b/src/Duempelkas.App/Components/Dialogs/EditNameDialog.razor index b998bf7..15163c4 100644 --- a/src/Duempelkas.App/Components/Dialogs/EditNameDialog.razor +++ b/src/Duempelkas.App/Components/Dialogs/EditNameDialog.razor @@ -1,14 +1,15 @@ -
-
+
@DialogTitle
+
- - + +
- - + +
-
-
+ + +@code { private ModalDialog? modal; } diff --git a/src/Duempelkas.App/Components/Dialogs/EditNameDialog.razor.cs b/src/Duempelkas.App/Components/Dialogs/EditNameDialog.razor.cs index cef5344..85a3c6a 100644 --- a/src/Duempelkas.App/Components/Dialogs/EditNameDialog.razor.cs +++ b/src/Duempelkas.App/Components/Dialogs/EditNameDialog.razor.cs @@ -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 } diff --git a/src/Duempelkas.App/Components/Dialogs/ModalDialog.razor b/src/Duempelkas.App/Components/Dialogs/ModalDialog.razor new file mode 100644 index 0000000..20a766f --- /dev/null +++ b/src/Duempelkas.App/Components/Dialogs/ModalDialog.razor @@ -0,0 +1,76 @@ +@implements IAsyncDisposable +@inject IJSRuntime JS + + + +

@Title

+
@ChildContent
+ @if (discardPrompt) + { +
+

Ungespeicherte Eingaben

+

Die Änderungen wurden noch nicht gespeichert. Möchtest du weiter bearbeiten oder die Eingaben verwerfen?

+
+ + +
+
+ } +
+ +@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? 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(); + } +} diff --git a/src/Duempelkas.App/Components/Layout/MainLayout.razor b/src/Duempelkas.App/Components/Layout/MainLayout.razor index 2e94763..edd4944 100644 --- a/src/Duempelkas.App/Components/Layout/MainLayout.razor +++ b/src/Duempelkas.App/Components/Layout/MainLayout.razor @@ -20,8 +20,7 @@ @if (showAboutDialog) { -
-
+
Über Dümpelkas · Version @AppVersion

Entwickler: Andre Beging

E-Mail: mail@beging.de

@@ -30,7 +29,6 @@ Schließen
-
- +
} diff --git a/src/Duempelkas.App/Components/ToastHost.razor b/src/Duempelkas.App/Components/ToastHost.razor index 0d0b7f2..f3555d1 100644 --- a/src/Duempelkas.App/Components/ToastHost.razor +++ b/src/Duempelkas.App/Components/ToastHost.razor @@ -1,7 +1,7 @@ @implements IDisposable @inject ToastService Toasts -
+
@foreach (var toast in Toasts.Messages) {
@code { + [Parameter] public bool InDialog { get; set; } private readonly HashSet running = []; protected override void OnInitialized() => Toasts.Changed += Refresh; private void Refresh() => _ = InvokeAsync(StateHasChanged); diff --git a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor index 72cdd4e..b9b687c 100644 --- a/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor +++ b/src/Duempelkas.App/Pages/Accounts/AccountDetail.razor @@ -184,8 +184,7 @@ @if (showYearFilterDialog) { -
-
+
Filter auswählen

Wähle ein Jahr oder alle Buchungen.

@@ -204,7 +203,6 @@ Schließen
-
-
+ } diff --git a/src/Duempelkas.App/Pages/Dashboard.razor b/src/Duempelkas.App/Pages/Dashboard.razor index 99c0406..c902d3e 100644 --- a/src/Duempelkas.App/Pages/Dashboard.razor +++ b/src/Duempelkas.App/Pages/Dashboard.razor @@ -116,8 +116,7 @@ @if (showYearFilterDialog) { -
-
+
Filter auswählen

Wähle ein Jahr oder alle Buchungen.

@@ -136,7 +135,6 @@ Schließen
-
-
+ } diff --git a/src/Duempelkas.Desktop/wwwroot/css/app.css b/src/Duempelkas.Desktop/wwwroot/css/app.css index af81a27..c84e7ed 100644 --- a/src/Duempelkas.Desktop/wwwroot/css/app.css +++ b/src/Duempelkas.Desktop/wwwroot/css/app.css @@ -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; } diff --git a/src/Duempelkas.Desktop/wwwroot/js/app.js b/src/Duempelkas.Desktop/wwwroot/js/app.js index ff7b5d1..663e2f6 100644 --- a/src/Duempelkas.Desktop/wwwroot/js/app.js +++ b/src/Duempelkas.Desktop/wwwroot/js/app.js @@ -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(); } +}; diff --git a/tests/Duempelkas.Tests/BookingFormTests.cs b/tests/Duempelkas.Tests/BookingFormTests.cs index 020c76f..c61098b 100644 --- a/tests/Duempelkas.Tests/BookingFormTests.cs +++ b/tests/Duempelkas.Tests/BookingFormTests.cs @@ -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(); context.Services.AddSingleton(new EntryService(null!)); var dialog = context.Render(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(); context.Services.AddSingleton(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); diff --git a/tests/Duempelkas.Tests/ModalDialogTests.cs b/tests/Duempelkas.Tests/ModalDialogTests.cs new file mode 100644 index 0000000..23cbb78 --- /dev/null +++ b/tests/Duempelkas.Tests/ModalDialogTests.cs @@ -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(); + var cancelled = 0; + var modal = context.Render(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(); + var cancelled = false; + var modal = context.Render(p => p.Add(x => x.Busy, true).Add(x => x.OnCancel, () => cancelled = true)); + await modal.InvokeAsync(() => modal.Instance.RequestCloseAsync()); + Assert.False(cancelled); + } +}