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
@@ -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>
<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>
</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
}
@@ -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>
<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>
</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 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>
<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>
</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 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>
<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>
</div>
</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);