feat(accessibility): add native keyboard dialogs and semantic account links (U08)
This commit is contained in:
@@ -59,3 +59,7 @@ Prüfung: 42/42 Debug-Tests, Desktop-Build ohne Warnungen. Computer Use in separ
|
|||||||
## Weiterer Umsetzungsplan
|
## 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: 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
|
@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">
|
<div class="card-body d-flex flex-column justify-content-between">
|
||||||
<h3 class="card-title mb-3 mt-0">@Account.Name</h3>
|
<h3 class="card-title mb-3 mt-0">@Account.Name</h3>
|
||||||
<div class="@(Account.TotalBalance >= 0 ? "amount-positive" : "amount-negative") fs-4">
|
<div class="@(Account.TotalBalance >= 0 ? "amount-positive" : "amount-negative") fs-4">
|
||||||
@($"{Account.TotalBalance:N2} €")
|
@($"{Account.TotalBalance:N2} €")
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<div class="dialog-backdrop">
|
<ModalDialog @ref="modal" Title="Neues Konto" OnCancel="OnCancel" Dirty="@(!string.IsNullOrWhiteSpace(name))">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5 class="mb-3">Neues Konto</h5>
|
<h5 class="mb-3">Neues Konto</h5>
|
||||||
|
<form @onsubmit="Save" @onsubmit:preventDefault="true">
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<label class="form-label">Kontoname</label>
|
<label class="form-label" for="account-name">Kontoname</label>
|
||||||
<input type="text" class="form-control" @bind="name" @bind:event="oninput"
|
<input id="account-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput"
|
||||||
placeholder="z.B. Girokonto" />
|
placeholder="z.B. Girokonto" />
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<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 type="submit" class="btn btn-success" 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>
|
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
@inject ToastService Toasts
|
@inject ToastService Toasts
|
||||||
@inject IEntryService EntryService
|
@inject IEntryService EntryService
|
||||||
|
|
||||||
<div class="dialog-backdrop">
|
<ModalDialog @ref="modal" Title="Einnahme oder Ausgabe" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && IsDirty)">
|
||||||
<div class="dialog-content booking-dialog" role="dialog" aria-modal="true" aria-labelledby="entry-dialog-title">
|
|
||||||
<h5 id="entry-dialog-title">@(EditEntry != null ? "Buchung bearbeiten" : "Einnahme oder Ausgabe erfassen") – @AccountName</h5>
|
<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")">
|
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
|
||||||
@@ -31,7 +30,7 @@
|
|||||||
|
|
||||||
<label class="form-label" for="entry-title">Beschreibung</label>
|
<label class="form-label" for="entry-title">Beschreibung</label>
|
||||||
<div>
|
<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> }
|
@if (string.IsNullOrWhiteSpace(title)) { <p id="entry-title-help" class="text-danger">Bitte beschreiben, wofür das Geld eingenommen oder ausgegeben wurde.</p> }
|
||||||
</div>
|
</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>
|
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</div>
|
|
||||||
|
@code { private ModalDialog? modal; }
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ public partial class AddEntryDialog
|
|||||||
private string amountText = string.Empty;
|
private string amountText = string.Empty;
|
||||||
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
|
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 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 bool initialized;
|
||||||
private int? initializedEntryId;
|
private int? initializedEntryId;
|
||||||
|
|
||||||
@@ -64,6 +67,7 @@ public partial class AddEntryDialog
|
|||||||
title = EditEntry.Title;
|
title = EditEntry.Title;
|
||||||
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
}
|
}
|
||||||
|
initialState = CurrentState;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -106,7 +110,7 @@ public partial class AddEntryDialog
|
|||||||
|
|
||||||
private async Task Cancel()
|
private async Task Cancel()
|
||||||
{
|
{
|
||||||
if (!isSaving) await OnCancel.InvokeAsync();
|
if (!isSaving && modal != null) await modal.RequestCloseAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
@inject IEntryService EntryService
|
@inject IEntryService EntryService
|
||||||
@inject IAccountService AccountService
|
@inject IAccountService AccountService
|
||||||
|
|
||||||
<div class="dialog-backdrop">
|
<ModalDialog @ref="modal" Title="Geld zwischen Konten verschieben" OnCancel="OnCancel" Busy="isSaving" Dirty="@(committedResult == null && IsDirty)">
|
||||||
<div class="dialog-content booking-dialog" @onclick:stopPropagation="true">
|
|
||||||
<h5 class="mb-3">@(EditEntry != null ? "Umbuchung bearbeiten" : "Neue Umbuchung")</h5>
|
<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>
|
<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")">
|
<form @onsubmit="Save" @onsubmit:preventDefault="true" aria-busy="@(isSaving ? "true" : "false")">
|
||||||
<fieldset disabled="@(isSaving || committedResult != null || completed)">
|
<fieldset disabled="@(isSaving || committedResult != null || completed)">
|
||||||
<div class="form-container">
|
<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">
|
<select id="transfer-account" class="form-select" @bind="targetAccountId">
|
||||||
<option value="0">— Auswählen —</option>
|
<option value="0">— Auswählen —</option>
|
||||||
@foreach (var acc in accounts.Where(a => a.Id != SourceAccountId))
|
@foreach (var acc in accounts.Where(a => a.Id != SourceAccountId))
|
||||||
@@ -20,13 +19,13 @@
|
|||||||
}
|
}
|
||||||
</select>
|
</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" />
|
<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" />
|
<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" />
|
<div><AmountInput Id="transfer-amount" @bind-Value="amountText" />
|
||||||
<p class="text-muted">Betrag ohne Minuszeichen.</p></div>
|
<p class="text-muted">Betrag ohne Minuszeichen.</p></div>
|
||||||
</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>
|
<button type="button" class="btn btn-outline-secondary" disabled="@isSaving" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
@code { private ModalDialog? modal; }
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ public partial class AddTransferDialog
|
|||||||
private string amountText = string.Empty;
|
private string amountText = string.Empty;
|
||||||
private bool ValidAmount => Duempelkas.App.Services.BookingInput.TryAmount(amountText, out _);
|
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 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 bool initialized;
|
||||||
private int? initializedEntryId;
|
private int? initializedEntryId;
|
||||||
|
|
||||||
@@ -72,6 +75,7 @@ public partial class AddTransferDialog
|
|||||||
title = EditEntry.Title;
|
title = EditEntry.Title;
|
||||||
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
amountText = EditEntry.Amount.ToString("0.00", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
|
||||||
}
|
}
|
||||||
|
initialState = CurrentState;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@@ -114,7 +118,7 @@ public partial class AddTransferDialog
|
|||||||
|
|
||||||
private async Task Cancel()
|
private async Task Cancel()
|
||||||
{
|
{
|
||||||
if (!isSaving) await OnCancel.InvokeAsync();
|
if (!isSaving && modal != null) await modal.RequestCloseAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<div class="dialog-backdrop">
|
<ModalDialog @ref="modal" Title="@Title" OnCancel="OnCancel" >
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5>@Title</h5>
|
<h5>@Title</h5>
|
||||||
<p>@Message</p>
|
<p>@Message</p>
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<div class="d-flex justify-content-end gap-2">
|
||||||
<button class="@ConfirmButtonClass" @onclick="Confirm"><i class="@ConfirmIconClass"></i> @ConfirmText</button>
|
<button type="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>
|
<button type="button" class="btn btn-outline-secondary" data-safe-focus @onclick="Cancel"><i class="bi bi-x-lg"></i> @CancelText</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</ModalDialog>
|
||||||
|
|
||||||
|
@code { private ModalDialog? modal; }
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public partial class ConfirmDialog
|
|||||||
|
|
||||||
private async Task Confirm() => await OnConfirm.InvokeAsync();
|
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
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<div class="dialog-backdrop">
|
<ModalDialog @ref="modal" Title="Bestand korrigieren" OnCancel="OnCancel" Dirty="@(amount != CurrentAmount)">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5 class="mb-3">Übertrag bearbeiten</h5>
|
<h5 class="mb-3">Übertrag bearbeiten</h5>
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<label class="form-label">Übertrag (€)</label>
|
<label class="form-label" for="carryover">Übertrag (€)</label>
|
||||||
<input type="number" class="form-control no-spinner" step="0.01" @bind="amount" />
|
<input id="carryover" data-initial-focus type="number" class="form-control no-spinner" step="0.01" @bind="amount" />
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<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 type="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>
|
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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 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
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<div class="dialog-backdrop">
|
<ModalDialog @ref="modal" Title="Name bearbeiten" OnCancel="OnCancel" Dirty="@(name != CurrentName)">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5 class="mb-3">@DialogTitle</h5>
|
<h5 class="mb-3">@DialogTitle</h5>
|
||||||
|
<form @onsubmit="Save" @onsubmit:preventDefault="true">
|
||||||
<div class="form-container">
|
<div class="form-container">
|
||||||
<label class="form-label">@NameLabel</label>
|
<label class="form-label" for="edit-name">@NameLabel</label>
|
||||||
<input type="text" class="form-control" @bind="name" @bind:event="oninput" />
|
<input id="edit-name" data-initial-focus type="text" class="form-control" @bind="name" @bind:event="oninput" />
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-end gap-2">
|
<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 type="submit" class="btn btn-success" 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>
|
<button type="button" class="btn btn-outline-secondary" @onclick="Cancel"><i class="bi bi-x-lg"></i> Abbrechen</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalDialog>
|
||||||
|
|
||||||
|
@code { private ModalDialog? modal; }
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ public partial class EditNameDialog
|
|||||||
|
|
||||||
#region Lifecycle
|
#region Lifecycle
|
||||||
|
|
||||||
protected override void OnParametersSet() => name = CurrentName;
|
private bool initialized;
|
||||||
|
protected override void OnParametersSet() { if (!initialized) { name = CurrentName; initialized = true; } }
|
||||||
|
|
||||||
#endregion
|
#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
|
#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)
|
@if (showAboutDialog)
|
||||||
{
|
{
|
||||||
<div class="dialog-backdrop">
|
<ModalDialog Title="Über Dümpelkas" OnCancel="CloseAboutDialog">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5>Über Dümpelkas · Version @AppVersion</h5>
|
<h5>Über Dümpelkas · Version @AppVersion</h5>
|
||||||
<p class="mb-2">Entwickler: Andre Beging</p>
|
<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>
|
<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
|
<i class="bi bi-x-lg"></i> Schließen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject ToastService Toasts
|
@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)
|
@foreach (var toast in Toasts.Messages)
|
||||||
{
|
{
|
||||||
<section @key="toast.Id" class="app-toast toast-@toast.Kind.ToString().ToLowerInvariant()"
|
<section @key="toast.Id" class="app-toast toast-@toast.Kind.ToString().ToLowerInvariant()"
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[Parameter] public bool InDialog { get; set; }
|
||||||
private readonly HashSet<Guid> running = [];
|
private readonly HashSet<Guid> running = [];
|
||||||
protected override void OnInitialized() => Toasts.Changed += Refresh;
|
protected override void OnInitialized() => Toasts.Changed += Refresh;
|
||||||
private void Refresh() => _ = InvokeAsync(StateHasChanged);
|
private void Refresh() => _ = InvokeAsync(StateHasChanged);
|
||||||
|
|||||||
@@ -184,8 +184,7 @@
|
|||||||
|
|
||||||
@if (showYearFilterDialog)
|
@if (showYearFilterDialog)
|
||||||
{
|
{
|
||||||
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
|
<ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5>Filter auswählen</h5>
|
<h5>Filter auswählen</h5>
|
||||||
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
||||||
<div class="filter-options-grid">
|
<div class="filter-options-grid">
|
||||||
@@ -204,7 +203,6 @@
|
|||||||
<i class="bi bi-x-lg"></i> Schließen
|
<i class="bi bi-x-lg"></i> Schließen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,8 +116,7 @@
|
|||||||
|
|
||||||
@if (showYearFilterDialog)
|
@if (showYearFilterDialog)
|
||||||
{
|
{
|
||||||
<div class="dialog-backdrop" @onclick="CloseYearFilterDialog">
|
<ModalDialog Title="Jahr auswählen" OnCancel="CloseYearFilterDialog">
|
||||||
<div class="dialog-content" @onclick:stopPropagation="true">
|
|
||||||
<h5>Filter auswählen</h5>
|
<h5>Filter auswählen</h5>
|
||||||
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
<p class="text-muted mb-3">Wähle ein Jahr oder alle Buchungen.</p>
|
||||||
<div class="filter-options-grid">
|
<div class="filter-options-grid">
|
||||||
@@ -136,7 +135,6 @@
|
|||||||
<i class="bi bi-x-lg"></i> Schließen
|
<i class="bi bi-x-lg"></i> Schließen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ModalDialog>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -630,3 +630,14 @@ fieldset { min-width: 0; }
|
|||||||
@keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
@keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } }
|
@media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } }
|
||||||
.input-rejection:empty { display: 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; }
|
||||||
|
|||||||
@@ -17,3 +17,16 @@
|
|||||||
if (e.target.matches('[data-amount-guard]') && !accept(e.target, e.clipboardData.getData('text').trim())) e.preventDefault();
|
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()
|
public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
|
||||||
{
|
{
|
||||||
using var context = new BunitContext();
|
using var context = new BunitContext();
|
||||||
|
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
context.Services.AddSingleton<ToastService>();
|
context.Services.AddSingleton<ToastService>();
|
||||||
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
|
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
|
||||||
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.AccountName, "Barkasse"));
|
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.AccountName, "Barkasse"));
|
||||||
@@ -40,6 +41,7 @@ public class BookingFormTests
|
|||||||
public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput()
|
public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput()
|
||||||
{
|
{
|
||||||
using var context = new BunitContext();
|
using var context = new BunitContext();
|
||||||
|
context.JSInterop.Mode = JSRuntimeMode.Loose;
|
||||||
context.Services.AddSingleton<ToastService>();
|
context.Services.AddSingleton<ToastService>();
|
||||||
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
|
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);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user