feat(usability): label booking actions and enlarge row targets (U09)
This commit is contained in:
@@ -63,3 +63,7 @@ U08: gemeinsame native Modaldialoge mit Fokus/Escape/Verlustschutz, semantische
|
|||||||
## U08 – Tastatur und Modaldialoge
|
## 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.
|
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.
|
||||||
|
|
||||||
|
## U09 – Zeilenaktionen
|
||||||
|
|
||||||
|
Beschriftetes Aktionsmenü je Buchung, sichtbares Bearbeiten/Löschen und Wiederherstellen als Wort. Zugängliche Namen nennen Nummer und Beschreibung, Trefferbereiche mindestens 44 CSS-Pixel. Auf schmalen Fenstern wechseln Zeilen in beschriftete Karten, damit die Aktionsspalte nicht die Beschreibung verdrängt. Gerenderter Test prüft Zuordnung und Aktion.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<tr class="@GetRowClass()">
|
<tr class="@GetRowClass()">
|
||||||
<td>@Entry.DisplayId</td>
|
<td data-label="Nummer">@Entry.DisplayId</td>
|
||||||
<td>@Entry.Date.ToString("dd.MM.yyyy")</td>
|
<td data-label="Datum">@Entry.Date.ToString("dd.MM.yyyy")</td>
|
||||||
<td>
|
<td data-label="Beschreibung" class="entry-description">
|
||||||
@Entry.Title
|
@Entry.Title
|
||||||
@if (Entry.IsTransfer && !string.IsNullOrEmpty(Entry.LinkedAccountName))
|
@if (Entry.IsTransfer && !string.IsNullOrEmpty(Entry.LinkedAccountName))
|
||||||
{
|
{
|
||||||
@@ -10,26 +10,27 @@
|
|||||||
</small>
|
</small>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end @(Entry.Type == EntryType.Income ? "amount-positive" : "amount-negative")">
|
<td data-label="Betrag" class="text-end @(Entry.Type == EntryType.Income ? "amount-positive" : "amount-negative")">
|
||||||
@(Entry.Type == EntryType.Income ? "+" : "\u2212")@($"{Entry.Amount:N2} €")
|
@(Entry.Type == EntryType.Income ? "+" : "\u2212")@($"{Entry.Amount:N2} €")
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Aktionen" class="entry-actions">
|
||||||
@if (Entry.IsDeleted)
|
@if (Entry.IsDeleted)
|
||||||
{
|
{
|
||||||
<button class="btn btn-outline-success btn-sm" @onclick="() => OnRestore.InvokeAsync(Entry.Id)" title="Wiederherstellen">
|
<button type="button" class="btn btn-outline-success" @onclick="() => OnRestore.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} wiederherstellen")">
|
||||||
<i class="bi bi-arrow-counterclockwise"></i>
|
<i class="bi bi-arrow-counterclockwise" aria-hidden="true"></i> Wiederherstellen
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="d-flex gap-1">
|
<details class="entry-action-menu">
|
||||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => OnEdit.InvokeAsync(Entry.Id)" title="Bearbeiten">
|
<summary aria-label="@($"Aktionen für {Entry.DisplayId} {Entry.Title}")">Aktionen</summary>
|
||||||
<i class="bi bi-pencil"></i>
|
<button type="button" class="btn btn-outline-secondary" @onclick="() => OnEdit.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} bearbeiten")">
|
||||||
|
<i class="bi bi-pencil" aria-hidden="true"></i> Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-danger btn-sm" @onclick="() => OnDelete.InvokeAsync(Entry.Id)" title="Löschen">
|
<button type="button" class="btn btn-outline-danger" @onclick="() => OnDelete.InvokeAsync(Entry.Id)" aria-label="@($"{Entry.DisplayId} {Entry.Title} löschen")">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash" aria-hidden="true"></i> Löschen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</details>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="table-responsive">
|
<div class="table-responsive entry-list">
|
||||||
<table class="table table-hover table-sm align-middle">
|
<table class="table table-hover table-sm align-middle">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<th style="width: 120px;">Datum</th>
|
<th style="width: 120px;">Datum</th>
|
||||||
<th>Bezeichnung</th>
|
<th>Bezeichnung</th>
|
||||||
<th style="width: 140px;" class="text-end">Betrag</th>
|
<th style="width: 140px;" class="text-end">Betrag</th>
|
||||||
<th style="width: 80px;"></th>
|
<th style="width: 11rem;">Aktionen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -641,3 +641,16 @@ fieldset { border: 0; margin: 0; padding: 0; }
|
|||||||
body:has(dialog[open]) .toast-stack:not(.modal-toasts) { display: none; }
|
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; }
|
.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; }
|
||||||
|
|
||||||
|
.entry-actions button, .entry-action-menu summary { min-height: 44px; padding: .6rem; }
|
||||||
|
.entry-action-menu summary { cursor: pointer; border: 1px solid var(--color-border); border-radius: .4rem; }
|
||||||
|
.entry-action-menu button { display: block; width: 100%; margin-top: .5rem; }
|
||||||
|
.entry-description { overflow-wrap: anywhere; }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.entry-list thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
|
||||||
|
.entry-list table, .entry-list tbody { display: block; width: 100%; }
|
||||||
|
.entry-list tbody tr { display: grid; grid-template-columns: 1fr 1fr; border: 1px solid var(--color-border); border-radius: .5rem; padding: .6rem; margin-bottom: .75rem; }
|
||||||
|
.entry-list td { display: block; border: 0; min-width: 0; padding: .3rem; text-align: left !important; }
|
||||||
|
.entry-list td::before { content: attr(data-label); display: block; color: var(--color-text-muted); font-size: .85rem; }
|
||||||
|
.entry-list .entry-description, .entry-list .entry-actions { grid-column: 1 / -1; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Bunit;
|
||||||
|
using Duempelkas.App.Components.Accounts;
|
||||||
|
using Duempelkas.App.Services.Models;
|
||||||
|
using Duempelkas.Domain.Enums;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Duempelkas.Tests;
|
||||||
|
|
||||||
|
public class EntryPresentationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Actions_HaveVisibleTextAndBookingSpecificNames()
|
||||||
|
{
|
||||||
|
using var context = new BunitContext();
|
||||||
|
var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, DateTime.Today, "Porto", 4.20m, false, false, null, null, null);
|
||||||
|
var edited = 0;
|
||||||
|
var table = context.Render<EntryTable>(p => p.Add(x => x.Entries, [entry]).Add(x => x.OnEditEntry, id => edited = id));
|
||||||
|
Assert.Contains("2026-001 Porto", table.Find("summary").GetAttribute("aria-label"));
|
||||||
|
var edit = table.FindAll("button").Single(b => b.TextContent.Contains("Bearbeiten"));
|
||||||
|
Assert.Contains("Porto", edit.GetAttribute("aria-label"));
|
||||||
|
edit.Click();
|
||||||
|
Assert.Equal(1, edited);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user