feat(ui): constrain decimal input and add stacked timed toasts with uniform actions
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<input id="@Id" type="text" inputmode="decimal" class="form-control" data-amount-guard
|
||||
data-allow-negative="@AllowNegative.ToString().ToLowerInvariant()"
|
||||
@bind:get="Value" @bind:set="SetValue" @bind:event="oninput" disabled="@Disabled"
|
||||
aria-describedby="@(Id + "-rejected")" />
|
||||
<span id="@(Id + "-rejected")" class="input-rejection text-danger" role="status">@rejection</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Id { get; set; } = "amount";
|
||||
[Parameter] public string Value { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ValueChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public bool AllowNegative { get; set; }
|
||||
private string? rejection;
|
||||
private async Task SetValue(string value)
|
||||
{
|
||||
if (!BookingInput.IsAmountDraft(value, AllowNegative))
|
||||
{
|
||||
rejection = "Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.";
|
||||
return;
|
||||
}
|
||||
rejection = null;
|
||||
await ValueChanged.InvokeAsync(value);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IEntryService EntryService
|
||||
|
||||
<div class="dialog-backdrop">
|
||||
@@ -36,8 +37,8 @@
|
||||
|
||||
<label class="form-label" for="entry-amount">Betrag (€)</label>
|
||||
<div>
|
||||
<input id="entry-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" placeholder="12,50" aria-describedby="entry-amount-help" aria-invalid="@(!ValidAmount ? "true" : "false")" />
|
||||
<p id="entry-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0. Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen, auch beim Einfügen. Es wird nicht gerundet.</p>
|
||||
<AmountInput Id="entry-amount" @bind-Value="amountText" />
|
||||
<p class="text-muted">Betrag ohne Minuszeichen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,13 +51,6 @@
|
||||
}
|
||||
|
||||
</fieldset>
|
||||
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
|
||||
@if (saveError != null)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">@saveError
|
||||
<details><summary>Technische Details</summary>@saveDetails</details>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||
|
||||
@@ -73,6 +73,7 @@ public partial class AddEntryDialog
|
||||
private async Task Save()
|
||||
{
|
||||
if (isSaving || completed || (committedResult == null && !CanSave)) return;
|
||||
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
saveDetails = null;
|
||||
@@ -98,8 +99,9 @@ public partial class AddEntryDialog
|
||||
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
|
||||
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
|
||||
saveDetails = ex.Message;
|
||||
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
|
||||
}
|
||||
finally { isSaving = false; }
|
||||
finally { isSaving = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IEntryService EntryService
|
||||
@inject IAccountService AccountService
|
||||
|
||||
@@ -26,8 +27,8 @@
|
||||
<input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
|
||||
|
||||
<label class="form-label">Betrag (€)</label>
|
||||
<div><input id="transfer-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" aria-describedby="transfer-amount-help" />
|
||||
<p id="transfer-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0; Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen; keine Rundung.</p></div>
|
||||
<div><AmountInput Id="transfer-amount" @bind-Value="amountText" />
|
||||
<p class="text-muted">Betrag ohne Minuszeichen.</p></div>
|
||||
</div>
|
||||
|
||||
@if (OutsideYear)
|
||||
@@ -39,13 +40,6 @@
|
||||
}
|
||||
|
||||
</fieldset>
|
||||
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
|
||||
@if (saveError != null)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">@saveError
|
||||
<details><summary>Technische Details</summary>@saveDetails</details>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||
|
||||
@@ -81,6 +81,7 @@ public partial class AddTransferDialog
|
||||
private async Task Save()
|
||||
{
|
||||
if (isSaving || completed || (committedResult == null && !CanSave)) return;
|
||||
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
saveDetails = null;
|
||||
@@ -106,8 +107,9 @@ public partial class AddTransferDialog
|
||||
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
|
||||
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
|
||||
saveDetails = ex.Message;
|
||||
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
|
||||
}
|
||||
finally { isSaving = false; }
|
||||
finally { isSaving = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<ToastHost />
|
||||
|
||||
<div class="d-flex flex-column vh-100">
|
||||
<nav class="app-navbar d-flex justify-content-between align-items-center">
|
||||
<a class="navbar-brand" href="">
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IFileSaveService FileSaveService
|
||||
|
||||
@if (Result != null)
|
||||
{
|
||||
<div class="alert @(Result.Status == FileOperationStatus.Error ? "alert-danger" : Result.Status == FileOperationStatus.Success ? "alert-success" : "alert-info")" role="status" aria-live="polite" aria-atomic="true">
|
||||
<p>@Result.Message</p>
|
||||
@if (Result.Path != null)
|
||||
{
|
||||
<p class="text-break">@Result.Path</p>
|
||||
<button class="btn btn-outline-secondary" disabled="@busy" @onclick="OpenFolder">Ordner öffnen</button>
|
||||
}
|
||||
@if (Result.Details != null)
|
||||
{
|
||||
<details><summary>Technische Details</summary><p class="text-break">@Result.Details</p></details>
|
||||
}
|
||||
@if (openResult != null)
|
||||
{
|
||||
<p role="alert">@openResult.Message</p>
|
||||
@if (openResult.Details != null) { <details><summary>Technische Details</summary>@openResult.Details</details> }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public FileOperationResult? Result { get; set; }
|
||||
private FileOperationResult? openResult;
|
||||
private FileOperationResult? previous;
|
||||
private bool busy;
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (previous != Result) { openResult = null; previous = Result; }
|
||||
}
|
||||
private async Task OpenFolder()
|
||||
{
|
||||
if (busy || Result?.Path == null) return;
|
||||
busy = true;
|
||||
try { openResult = await FileSaveService.OpenFolderAsync(Result.Path); }
|
||||
finally { busy = false; }
|
||||
if (Result == null || ReferenceEquals(previous, Result)) return;
|
||||
previous = Result;
|
||||
var result = Result;
|
||||
Toasts.Show(result.Message + (result.Path != null ? " " + result.Path : ""),
|
||||
result.Status == FileOperationStatus.Error ? ToastKind.Error : result.Status == FileOperationStatus.Success ? ToastKind.Success : ToastKind.Info,
|
||||
result.Details, result.Path != null ? "Ordner öffnen" : null,
|
||||
result.Path != null ? async () => {
|
||||
var opened = await FileSaveService.OpenFolderAsync(result.Path);
|
||||
Toasts.Show(opened.Message, opened.Status == FileOperationStatus.Error ? ToastKind.Error : ToastKind.Info, opened.Details);
|
||||
} : null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
@implements IDisposable
|
||||
@inject ToastService Toasts
|
||||
|
||||
<div class="toast-stack" aria-label="Meldungen">
|
||||
@foreach (var toast in Toasts.Messages)
|
||||
{
|
||||
<section @key="toast.Id" class="app-toast toast-@toast.Kind.ToString().ToLowerInvariant()"
|
||||
role="@(toast.Kind == ToastKind.Error ? "alert" : "status")" aria-live="@(toast.Kind == ToastKind.Error ? "assertive" : "polite")" aria-atomic="true"
|
||||
@onmouseenter="() => Toasts.Pause(toast.Id)" @onmouseleave="() => Toasts.Resume(toast.Id)"
|
||||
@onfocusin="() => Toasts.Pause(toast.Id)" @onfocusout="() => Toasts.Resume(toast.Id)">
|
||||
<button type="button" class="toast-close" aria-label="Meldung schließen" @onclick="() => Toasts.Dismiss(toast.Id)">×</button>
|
||||
<p>@toast.Message</p>
|
||||
@if (toast.Action != null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => RunAction(toast)">@toast.ActionLabel</button>
|
||||
}
|
||||
@if (toast.Details != null)
|
||||
{
|
||||
<details><summary>Technische Details</summary><p class="text-break">@toast.Details</p></details>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private readonly HashSet<Guid> running = [];
|
||||
protected override void OnInitialized() => Toasts.Changed += Refresh;
|
||||
private void Refresh() => _ = InvokeAsync(StateHasChanged);
|
||||
private async Task RunAction(ToastMessage toast)
|
||||
{
|
||||
if (!running.Add(toast.Id)) return;
|
||||
Toasts.Pause(toast.Id);
|
||||
try { await toast.Action!(); }
|
||||
catch (Exception ex) { Toasts.Show("Die Aktion konnte nicht abgeschlossen werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
|
||||
finally { running.Remove(toast.Id); Toasts.Resume(toast.Id); }
|
||||
}
|
||||
public void Dispose() => Toasts.Changed -= Refresh;
|
||||
}
|
||||
Reference in New Issue
Block a user