feat(accessibility): add native keyboard dialogs and semantic account links (U08)
This commit is contained in:
@@ -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 · 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);
|
||||
|
||||
Reference in New Issue
Block a user