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_retry": "Erneut versuchen",
|
||||||
"bulk_edit_title": "Massenbearbeitung",
|
"bulk_edit_title": "Massenbearbeitung",
|
||||||
"bulk_edit_save_button": "Anwenden",
|
"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_retry": "Retry",
|
||||||
"bulk_edit_title": "Bulk edit",
|
"bulk_edit_title": "Bulk edit",
|
||||||
"bulk_edit_save_button": "Apply",
|
"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_retry": "Reintentar",
|
||||||
"bulk_edit_title": "Edición masiva",
|
"bulk_edit_title": "Edición masiva",
|
||||||
"bulk_edit_save_button": "Aplicar",
|
"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'}
|
{#if mode === 'edit'}
|
||||||
<!-- Onboarding callout: tells the user that empty fields are skipped
|
<!-- 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
|
<div
|
||||||
role="note"
|
role="note"
|
||||||
aria-label="Hinweis zur Massenbearbeitung"
|
|
||||||
data-testid="bulk-edit-callout"
|
data-testid="bulk-edit-callout"
|
||||||
class="rounded-sm border border-accent/40 bg-accent/15 px-4 py-3 text-sm text-ink-2"
|
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();
|
let { canWrite }: { canWrite: boolean } = $props();
|
||||||
|
|
||||||
const count = $derived(bulkSelectionStore.size);
|
const count = $derived(bulkSelectionStore.size);
|
||||||
|
const visible = $derived(canWrite && count > 0);
|
||||||
|
|
||||||
function openBulkEdit() {
|
function openBulkEdit() {
|
||||||
goto('/documents/bulk-edit');
|
goto('/documents/bulk-edit');
|
||||||
@@ -14,16 +15,37 @@ function openBulkEdit() {
|
|||||||
function clearAll() {
|
function clearAll() {
|
||||||
bulkSelectionStore.clear();
|
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>
|
</script>
|
||||||
|
|
||||||
{#if canWrite && count > 0}
|
<svelte:window onkeydown={onEscape} />
|
||||||
|
|
||||||
|
{#if visible}
|
||||||
<div
|
<div
|
||||||
data-testid="bulk-selection-bar"
|
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">
|
<div class="flex items-baseline gap-3">
|
||||||
{m.bulk_edit_n_selected({ count })}
|
<span
|
||||||
</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">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
data-testid="bulk-clear-all"
|
||||||
>
|
>
|
||||||
{m.bulk_edit_clear_all()}
|
{m.bulk_edit_clear_selection()}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -46,4 +46,27 @@ describe('BulkSelectionBar', () => {
|
|||||||
await page.getByTestId('bulk-edit-open').click();
|
await page.getByTestId('bulk-edit-open').click();
|
||||||
expect(vi.mocked(goto)).toHaveBeenCalledWith('/documents/bulk-edit');
|
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}
|
{#if editMode}
|
||||||
<!-- Karton (only in editMode — bulk-editable replace) -->
|
<!-- Karton (only in editMode — bulk-editable replace) -->
|
||||||
<div data-testid="description-archive-box">
|
<div data-testid="description-archive-box">
|
||||||
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2"
|
<label for="archiveBox" class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
>Karton
|
{m.form_label_archive_box()}
|
||||||
<FieldLabelBadge variant="replace" />
|
<FieldLabelBadge variant="replace" />
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -132,12 +132,13 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
|
|||||||
bind:value={archiveBox}
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Mappe (only in editMode — bulk-editable replace) -->
|
<!-- Mappe (only in editMode — bulk-editable replace) -->
|
||||||
<div data-testid="description-archive-folder">
|
<div data-testid="description-archive-folder">
|
||||||
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2"
|
<label for="archiveFolder" class="mb-1 block text-sm font-medium text-ink-2">
|
||||||
>Mappe
|
{m.form_label_archive_folder()}
|
||||||
<FieldLabelBadge variant="replace" />
|
<FieldLabelBadge variant="replace" />
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -147,6 +148,7 @@ const titleValue = $derived(titleDirty ? currentTitle : suggestedTitle || curren
|
|||||||
bind:value={archiveFolder}
|
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"
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const text = $derived(
|
|||||||
|
|
||||||
<span
|
<span
|
||||||
data-testid="field-label-badge-{variant}"
|
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}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -21,10 +21,8 @@ describe('FieldLabelBadge', () => {
|
|||||||
.toHaveTextContent('wird ersetzt');
|
.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' });
|
render(FieldLabelBadge, { variant: 'replace' });
|
||||||
await expect
|
await expect.element(page.getByTestId('field-label-badge-replace')).toHaveClass(/text-ink-2/);
|
||||||
.element(page.getByTestId('field-label-badge-replace'))
|
|
||||||
.toHaveClass(/text-gray-600/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -200,7 +200,13 @@ $effect(() => {
|
|||||||
<title>{m.nav_documents()} – Familienarchiv</title>
|
<title>{m.nav_documents()} – Familienarchiv</title>
|
||||||
</svelte:head>
|
</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>
|
<h1 class="sr-only">{m.nav_documents()}</h1>
|
||||||
|
|
||||||
<SearchFilterBar
|
<SearchFilterBar
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const count = $derived(documents.length);
|
|||||||
const canWrite = $derived(data.canWrite);
|
const canWrite = $derived(data.canWrite);
|
||||||
</script>
|
</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 -->
|
<!-- Back Link -->
|
||||||
<BackButton />
|
<BackButton />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user