diff --git a/CLAUDE.md b/CLAUDE.md index 3dd52a15..d0b67fe4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -311,13 +311,15 @@ Save bar pattern — use **sticky full-bleed** for long forms (edit document), *
``` -Back link pattern: +Back button pattern — use the shared `` component from `$lib/components/BackButton.svelte`: ```svelte - - - Zurück zur Übersicht - + + + ``` +The component calls `history.back()` so the user returns to wherever they came from. Label is always "Zurück" (no contextual suffix — destination is unknown). Touch target ≥ 44px and focus ring are built in. Do not use a static `` for back navigation. Subtle action link (e.g. "new document/person"): ```svelte diff --git a/frontend/e2e/auth.setup.ts b/frontend/e2e/auth.setup.ts index 4d185e0f..e7fa4361 100644 --- a/frontend/e2e/auth.setup.ts +++ b/frontend/e2e/auth.setup.ts @@ -12,12 +12,12 @@ const authFile = path.join(__dirname, '.auth/user.json'); * E2E_PASSWORD (default: admin123) */ setup('authenticate', async ({ page }) => { - const username = process.env.E2E_USERNAME ?? 'admin'; + const username = process.env.E2E_USERNAME ?? 'admin@familyarchive.local'; const password = process.env.E2E_PASSWORD ?? 'admin123'; await page.goto('/login'); - await page.getByLabel('Benutzername').fill(username); - await page.getByLabel('Passwort').fill(password); + await page.getByLabel(/e-mail/i).fill(username); + await page.getByLabel(/passwort/i).fill(password); await page.getByRole('button', { name: 'Anmelden' }).click(); await page.waitForURL('/'); diff --git a/frontend/e2e/back-button.spec.ts b/frontend/e2e/back-button.spec.ts new file mode 100644 index 00000000..dcc94bd8 --- /dev/null +++ b/frontend/e2e/back-button.spec.ts @@ -0,0 +1,61 @@ +import AxeBuilder from '@axe-core/playwright'; +import { test, expect } from '@playwright/test'; + +// [data-hydrated] is set by +layout.svelte once SvelteKit's client-side hydration is complete. +// Waiting on it ensures the component is interactive before we interact with it. + +test.describe('BackButton — navigation', () => { + test('returns to previous page via history when clicked', async ({ page }) => { + // Navigate to persons list, then to a person detail + await page.goto('/persons'); + await page.waitForSelector('[data-hydrated]'); + + const firstPersonLink = page.locator('a[href^="/persons/"]').first(); + const personHref = await firstPersonLink.getAttribute('href'); + await firstPersonLink.click(); + await page.waitForURL(/\/persons\/.+/); + + // Now navigate to the edit page from the detail page + const editLink = page.locator('a[href$="/edit"]').first(); + await editLink.click(); + await page.waitForURL(/\/persons\/.+\/edit/); + + // Click the BackButton — should return to person detail, not /persons list + const backBtn = page.getByRole('button', { name: /zurück/i }); + await expect(backBtn).toBeVisible(); + await backBtn.click(); + + // Should return to the person detail URL (history.back()), not the static /persons + await expect(page).toHaveURL(new RegExp(personHref!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + }); +}); + +test.describe('BackButton — accessibility', () => { + test('touch target is at least 44px tall on /persons/new', async ({ page }) => { + await page.goto('/persons/new'); + await page.waitForSelector('[data-hydrated]'); + + const backBtn = page.getByRole('button', { name: /zurück/i }); + await expect(backBtn).toBeVisible(); + + const box = await backBtn.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + }); + + test('passes axe-core wcag2a/wcag2aa scan on /persons/new', async ({ page }) => { + await page.goto('/persons/new'); + await page.waitForSelector('[data-hydrated]'); + + const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); + + if (results.violations.length > 0) { + const summary = results.violations + .map((v) => `[${v.impact}] ${v.id}: ${v.description}`) + .join('\n'); + console.log(`\nAxe violations on /persons/new:\n${summary}`); + } + + expect(results.violations).toEqual([]); + }); +}); diff --git a/frontend/e2e/document-topbar-back.spec.ts b/frontend/e2e/document-topbar-back.spec.ts new file mode 100644 index 00000000..ef0bb13f --- /dev/null +++ b/frontend/e2e/document-topbar-back.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '@playwright/test'; + +test.describe('DocumentTopBar — back navigation', () => { + test('BackButton returns to /documents after navigating from the documents list', async ({ + page + }) => { + // Navigate to home page first (mirrors the real user flow) + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + // Click the Dokumente nav link (SPA navigation — pushes to history) + await page.click('a[href="/documents"]'); + await page.waitForURL('/documents'); + await page.waitForSelector('[data-hydrated]'); + + // Click first real document link (skip /documents/new and edit links) + const docLink = page.locator('a[href^="/documents/"]:not([href="/documents/new"])').first(); + const count = await docLink.count(); + + if (count === 0) { + test.skip(true, 'No documents in test database'); + } + + const docHref = await docLink.getAttribute('href'); + await docLink.click(); + await page.waitForURL(new RegExp(docHref!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + await page.waitForSelector('[data-hydrated]'); + + // Find and click the BackButton in the DocumentTopBar + const backBtn = page.locator('[data-topbar]').getByRole('button', { name: /zurück/i }); + await expect(backBtn).toBeVisible(); + await backBtn.click(); + + // Should be back at the documents list, not at / + await page.waitForURL(/\/documents($|\?)/); + expect(page.url()).toMatch(/\/documents($|\?)/); + }); +}); diff --git a/frontend/src/lib/components/BackButton.svelte b/frontend/src/lib/components/BackButton.svelte new file mode 100644 index 00000000..622cd726 --- /dev/null +++ b/frontend/src/lib/components/BackButton.svelte @@ -0,0 +1,26 @@ + + + diff --git a/frontend/src/lib/components/BackButton.svelte.spec.ts b/frontend/src/lib/components/BackButton.svelte.spec.ts new file mode 100644 index 00000000..5df58856 --- /dev/null +++ b/frontend/src/lib/components/BackButton.svelte.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import BackButton from './BackButton.svelte'; + +afterEach(cleanup); + +describe('BackButton', () => { + it('renders a button with "Zurück" text', async () => { + render(BackButton); + await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument(); + }); + + it('calls history.back() when clicked', async () => { + const backSpy = vi.spyOn(history, 'back').mockImplementation(() => {}); + render(BackButton); + + await page.getByRole('button', { name: /zurück/i }).click(); + + expect(backSpy).toHaveBeenCalledOnce(); + backSpy.mockRestore(); + }); + + it('applies mb-4 by default', async () => { + render(BackButton); + const btn = document.querySelector('button'); + expect(btn?.className).toContain('mb-4'); + }); + + it('applies custom class prop instead of default', async () => { + render(BackButton, { props: { class: 'mr-3 md:hidden' } }); + const btn = document.querySelector('button'); + expect(btn?.className).toContain('mr-3'); + expect(btn?.className).not.toContain('mb-4'); + }); +}); diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte index 995077a4..614ae9f5 100644 --- a/frontend/src/lib/components/DocumentTopBar.svelte +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -6,6 +6,7 @@ import { clickOutside } from '$lib/actions/clickOutside'; import PersonChipRow from './PersonChipRow.svelte'; import OverflowPillButton from './OverflowPillButton.svelte'; import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte'; +import BackButton from './BackButton.svelte'; type Person = { id: string; firstName?: string | null; lastName: string; displayName: string }; type Tag = { id: string; name: string }; @@ -132,19 +133,8 @@ let mobileMenuOpen = $state(false);
- -
- - + +
diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte index 738bc2f6..a9a19568 100644 --- a/frontend/src/routes/admin/users/[id]/+page.svelte +++ b/frontend/src/routes/admin/users/[id]/+page.svelte @@ -23,9 +23,11 @@ $effect(() => {
- history.back()} + aria-label={m.btn_back()} + class="mr-3 inline-flex items-center gap-1 text-xs text-ink-3 outline-none hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring md:hidden" > { > - +

{m.admin_user_edit_heading({ username: data.editUser.email })}

diff --git a/frontend/src/routes/documents/[id]/edit/+page.svelte b/frontend/src/routes/documents/[id]/edit/+page.svelte index 936407b4..fcd0f2a9 100644 --- a/frontend/src/routes/documents/[id]/edit/+page.svelte +++ b/frontend/src/routes/documents/[id]/edit/+page.svelte @@ -2,6 +2,7 @@ import { enhance } from '$app/forms'; import { m } from '$lib/paraglide/messages.js'; import { getConfirmService } from '$lib/services/confirm.svelte.js'; +import BackButton from '$lib/components/BackButton.svelte'; import DocumentEditLayout from '$lib/components/document/DocumentEditLayout.svelte'; let { data, form } = $props(); @@ -26,18 +27,7 @@ async function handleDelete() { {#snippet topbar()} - - - {m.btn_back_to_document()} - +

{doc.title || doc.originalFilename} diff --git a/frontend/src/routes/enrich/+page.svelte b/frontend/src/routes/enrich/+page.svelte index f23a53e0..b2130465 100644 --- a/frontend/src/routes/enrich/+page.svelte +++ b/frontend/src/routes/enrich/+page.svelte @@ -1,5 +1,6 @@

- - - - - {m.btn_back_to_overview()} - +

{m.persons_new_heading()}