fix(bulk-edit): a11y + i18n hardening (Leonie blockers 1–4 + quick concerns)

B1 — i18n the archive-box / archive-folder labels and add helper text.
Karton/Mappe were hardcoded German and broke EN/ES locales (WCAG 3.1.2).

B2 — drop the hardcoded German aria-label on the onboarding callout.
role="note" + the visible localised text is self-describing; the redundant
label was overriding the translated content for AT users on EN/ES.

B3 — Escape clears the bulk selection while the bar is visible. Adds an
"Esc: Auswahl aufheben" hint visible at ≥ sm (WCAG 2.1.1).

B4 — /documents and /enrich reserve pb-32 when the bulk-selection bar is
visible so it doesn't occlude the last row or pagination (WCAG 1.4.10).

Folded in three Leonie quick-concerns:
 - C5: badge text-[10px] → text-[11px], raw text-gray-600 →
        design-token text-ink-2 (dark-mode safe)
 - C7: aria-live="polite" on bulk-selection-count
 - C11: "Alles aufheben" → "Auswahl aufheben" (DE/EN/ES) — disambiguates
        from "discard the operation entirely"

Refs #225, PR #331

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-25 16:35:40 +02:00
parent 499beca124
commit 156efe8b31
11 changed files with 95 additions and 22 deletions

View File

@@ -887,5 +887,11 @@
"bulk_edit_retry": "Erneut versuchen",
"bulk_edit_title": "Massenbearbeitung",
"bulk_edit_save_button": "Anwenden",
"error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage."
"error_bulk_edit_too_many_ids": "Maximal 500 Dokumente pro Anfrage.",
"form_label_archive_box": "Karton",
"form_helper_archive_box": "Welcher Karton im Archiv?",
"form_label_archive_folder": "Mappe",
"form_helper_archive_folder": "Welche Mappe innerhalb des Kartons?",
"bulk_edit_clear_selection": "Auswahl aufheben",
"bulk_edit_clear_hint_keyboard": "Esc: Auswahl aufheben"
}

View File

@@ -887,5 +887,11 @@
"bulk_edit_retry": "Retry",
"bulk_edit_title": "Bulk edit",
"bulk_edit_save_button": "Apply",
"error_bulk_edit_too_many_ids": "Maximum 500 documents per request."
"error_bulk_edit_too_many_ids": "Maximum 500 documents per request.",
"form_label_archive_box": "Box",
"form_helper_archive_box": "Which box in the archive?",
"form_label_archive_folder": "Folder",
"form_helper_archive_folder": "Which folder inside the box?",
"bulk_edit_clear_selection": "Clear selection",
"bulk_edit_clear_hint_keyboard": "Esc: clear selection"
}

View File

@@ -887,5 +887,11 @@
"bulk_edit_retry": "Reintentar",
"bulk_edit_title": "Edición masiva",
"bulk_edit_save_button": "Aplicar",
"error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud."
"error_bulk_edit_too_many_ids": "Máximo 500 documentos por solicitud.",
"form_label_archive_box": "Caja",
"form_helper_archive_box": "¿Qué caja del archivo?",
"form_label_archive_folder": "Carpeta",
"form_helper_archive_folder": "¿Qué carpeta dentro de la caja?",
"bulk_edit_clear_selection": "Limpiar selección",
"bulk_edit_clear_hint_keyboard": "Esc: limpiar selección"
}

View File

@@ -379,10 +379,12 @@ async function retrySave() {
>
{#if mode === 'edit'}
<!-- Onboarding callout: tells the user that empty fields are skipped
and that tags/receivers are added rather than replaced. -->
and that tags/receivers are added rather than replaced.
No aria-label — role=note + the visible text content is
self-describing; an aria-label would override that text for
AT users on non-DE locales. -->
<div
role="note"
aria-label="Hinweis zur Massenbearbeitung"
data-testid="bulk-edit-callout"
class="rounded-sm border border-accent/40 bg-accent/15 px-4 py-3 text-sm text-ink-2"
>

View File

@@ -6,6 +6,7 @@ import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
let { canWrite }: { canWrite: boolean } = $props();
const count = $derived(bulkSelectionStore.size);
const visible = $derived(canWrite && count > 0);
function openBulkEdit() {
goto('/documents/bulk-edit');
@@ -14,16 +15,37 @@ function openBulkEdit() {
function clearAll() {
bulkSelectionStore.clear();
}
// Escape clears the selection — keyboard escape hatch when the user has
// drilled into a 50-row selection and wants to bail without Tab-ing through
// the whole footer (WCAG 2.1.1).
function onEscape(e: KeyboardEvent) {
if (e.key === 'Escape' && visible) {
clearAll();
}
}
</script>
{#if canWrite && count > 0}
<svelte:window onkeydown={onEscape} />
{#if visible}
<div
data-testid="bulk-selection-bar"
class="fixed right-0 bottom-0 left-0 z-30 flex items-center justify-between border-t border-line bg-surface px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6"
class="fixed right-0 bottom-0 left-0 z-30 flex items-center justify-between gap-3 border-t border-line bg-surface px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:px-6"
>
<span class="font-sans text-sm font-medium text-ink" data-testid="bulk-selection-count">
{m.bulk_edit_n_selected({ count })}
</span>
<div class="flex items-baseline gap-3">
<span
class="font-sans text-sm font-medium text-ink"
data-testid="bulk-selection-count"
aria-live="polite"
aria-atomic="true"
>
{m.bulk_edit_n_selected({ count })}
</span>
<span class="hidden font-sans text-xs text-ink-3 sm:inline">
{m.bulk_edit_clear_hint_keyboard()}
</span>
</div>
<div class="flex items-center gap-2">
<button
type="button"
@@ -31,7 +53,7 @@ function clearAll() {
class="inline-flex min-h-[44px] items-center px-4 py-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
data-testid="bulk-clear-all"
>
{m.bulk_edit_clear_all()}
{m.bulk_edit_clear_selection()}
</button>
<button
type="button"

View File

@@ -46,4 +46,27 @@ describe('BulkSelectionBar', () => {
await page.getByTestId('bulk-edit-open').click();
expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit');
});
it('selection count region announces via aria-live=polite', async () => {
bulkSelectionStore.add('a');
render(BulkSelectionBar, { canWrite: true });
await expect
.element(page.getByTestId('bulk-selection-count'))
.toHaveAttribute('aria-live', 'polite');
});
it('Escape clears the selection while the bar is visible', async () => {
bulkSelectionStore.add('a');
bulkSelectionStore.add('b');
render(BulkSelectionBar, { canWrite: true });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await expect.poll(() => bulkSelectionStore.size).toBe(0);
});
it('Escape is a no-op when the bar is hidden (no selection)', async () => {
render(BulkSelectionBar, { canWrite: true });
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
// Nothing to clear, no error.
expect(bulkSelectionStore.size).toBe(0);
});
});

View File

@@ -121,8 +121,8 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
{#if editMode}
<!-- Karton (only in editMode — bulk-editable replace) -->
<div data-testid="description-archive-box">
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2"
>Karton
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_box()}
<FieldLabelBadge variant="replace" />
</label>
<input
@@ -132,12 +132,13 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
bind:value={archiveBox}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_box()}</p>
</div>
<!-- Mappe (only in editMode — bulk-editable replace) -->
<div data-testid="description-archive-folder">
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2"
>Mappe
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
{m.form_label_archive_folder()}
<FieldLabelBadge variant="replace" />
</label>
<input
@@ -147,6 +148,7 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
bind:value={archiveFolder}
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_folder()}</p>
</div>
{/if}
</div>

View File

@@ -10,7 +10,7 @@ const text = $derived(
<span
data-testid="field-label-badge-{variant}"
class="ml-2 inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-medium tracking-wide text-gray-600"
class="ml-2 inline-flex items-center rounded-sm bg-muted px-1.5 py-0.5 text-[11px] font-medium tracking-wide text-ink-2"
>
{text}
</span>

View File

@@ -21,10 +21,8 @@ describe('FieldLabelBadge', () => {
.toHaveTextContent('wird ersetzt');
});
it('uses text-gray-600 for WCAG-AA contrast on muted backgrounds', async () => {
it('uses the design-system text-ink-2 token (not raw Tailwind palette)', async () => {
render(FieldLabelBadge, { variant: 'replace' });
await expect
.element(page.getByTestId('field-label-badge-replace'))
.toHaveClass(/text-gray-600/);
await expect.element(page.getByTestId('field-label-badge-replace')).toHaveClass(/text-ink-2/);
});
});

View File

@@ -200,7 +200,13 @@ $effect(() => {
<title>{m.nav_documents()} Familienarchiv</title>
</svelte:head>
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
<!-- Reserve bottom padding when the bulk-selection bar is visible so the
sticky bar does not occlude the last document row or the pagination
controls (WCAG 1.4.10 / 2.4.7). -->
<main
class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8"
class:pb-32={bulkSelectionStore.size > 0 && data.canWrite}
>
<h1 class="sr-only">{m.nav_documents()}</h1>
<SearchFilterBar

View File

@@ -10,7 +10,9 @@ const count = $derived(documents.length);
const canWrite = $derived(data.canWrite);
</script>
<div class="mx-auto max-w-4xl px-4 py-10">
<!-- Reserve bottom padding when the bulk-selection bar is visible so the
sticky bar does not occlude the last document row (WCAG 1.4.10). -->
<div class="mx-auto max-w-4xl px-4 py-10" class:pb-32={bulkSelectionStore.size > 0 && canWrite}>
<!-- Back Link -->
<BackButton />