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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user