From 7a6b3d66fb25ec37b2c41288748620a16d75df3a Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 8 Apr 2026 21:42:17 +0200 Subject: [PATCH 01/15] docs(spec): add design spec for person title & type fields UI Covers segmented type control, title input, conditional field visibility, PersonCard title display, mobile layout, and a11y. Co-Authored-By: Claude Opus 4.6 --- docs/specs/person-title-type-fields-spec.html | 1020 +++++++++++++++++ 1 file changed, 1020 insertions(+) create mode 100644 docs/specs/person-title-type-fields-spec.html diff --git a/docs/specs/person-title-type-fields-spec.html b/docs/specs/person-title-type-fields-spec.html new file mode 100644 index 00000000..71c447f0 --- /dev/null +++ b/docs/specs/person-title-type-fields-spec.html @@ -0,0 +1,1020 @@ + + + + + +Person Title & Type Fields — Design Spec · Familienarchiv + + + +
+ + +
+
+
+

Person Title & Type Fields — Design Spec

+

Surface the existing title and personType fields in the person edit/create forms, detail card, and list cards. Adds conditional field visibility based on entity type.

+
+ Final · Ready for implementation +
+
+
+
Title field
+
Backend only
→ Editable in forms
+
+
+
Type selector
+
Import only
→ Segmented control
+
+
+
SKIP value
+
Hidden from UI
(import-only type)
+
+
+
Conditional fields
+
Institution/Group hide
title, firstName, birth/death
+
+
+
Detail card
+
Title above name
small-caps label
+
+
+
List card
+
No change needed
displayName includes title
+
+
+
Mobile
+
Title + firstName share row
Type stacks vertically
+
+
+
Backend DTO
+
Add personType
to PersonUpdateDTO
+
+
+
+ + +
+ 📐 Mockup scale notice — all font-size, height, padding, and spacing values in the mockup CSS below are scaled to ~55% of their real implementation values so they fit on screen. Do not copy sizes from the mockup HTML/CSS. Each section ends with an ⚙ Implementation Reference table listing the exact Tailwind classes and real pixel values to use in code. +
+ + + +
+

What changes vs. current implementation

+
+
+

New / changed

+
    +
  • PersonEditForm: segmented type control added above name fields
  • +
  • PersonEditForm: title input (narrow column) added before firstName
  • +
  • PersonEditForm: conditional visibility — INSTITUTION/GROUP hide title, firstName, birthYear, deathYear; lastName relabeled to "Name"
  • +
  • PersonEditForm: UNKNOWN shows lastName + notes only
  • +
  • New person form: same type selector + title field + conditional logic
  • +
  • PersonCard (detail): title rendered above displayName as small-caps label
  • +
  • PersonUpdateDTO (backend): add personType field
  • +
  • +page.server.ts (edit & new): read and submit title + personType
  • +
+
+
+

Kept unchanged

+
    +
  • PersonTypeBadge component — still used as-is in detail + list
  • +
  • List card layout — displayName already includes title via backend formatter
  • +
  • Avatar logic — type-based icons vs. initials unchanged
  • +
  • NameHistoryEditCard — no changes
  • +
  • PersonDangerZone — no changes
  • +
  • PersonEditSaveBar — no changes
  • +
+
+
+
+ + + +
+
1 Edit Form — Type Selector & Title Field
+ +
+ +
+
Person selected max-w-2xl changed
+
+
/persons/abc-123/edit
+
DokumentePersonen
+
+ +
Prof. Dr. Heinrich Raddatz
+
Person bearbeiten
+ +
+
Details
+ + +
+
+ + Person +
+
+ + Institution +
+
+ + Gruppe +
+
+ + Unbekannt +
+
+ + +
+
+
Titel
+
Prof. Dr.
+
+
+
Vorname *
+
Heinrich
+
+
+
Nachname *
+
Raddatz
+
+
+ + +
+
+
Alias
+
z.B. Spitzname, Geburtsname…
+
+
+ + +
+
+
Geburtsjahr
+
1878
+
+
+
Sterbejahr
+
1944
+
+
+ + +
+
+
Notizen
+
Biografische Notizen…
+
+
+
+ +
+ Verwerfen + Speichern +
+
+
+
PERSON type: all fields visible. Title field is a narrow input (max-w-[120px]) before firstName. Three columns on md+.
+
+ + +
+
Institution selected max-w-2xl new behavior
+
+
/persons/def-456/edit
+
DokumentePersonen
+
+
Reichsfechtschule Leipzig
+
Person bearbeiten
+ +
+
Details
+ + +
+
+ + Person +
+
+ + Institution +
+
+ + Gruppe +
+
+ + Unbekannt +
+
+ + +
+
+
Name *
+
Reichsfechtschule Leipzig
+
+
+ + +
+
+
Alias
+
z.B. Kurzname, früherer Name…
+
+
+ + +
+
+
Notizen
+
Historische Anmerkungen…
+
+
+ +
+ Conditional fields: Title, Vorname, Geburtsjahr, and Sterbejahr are hidden when type is INSTITUTION or GROUP. The "Nachname" label changes to "Name". +
+
+ +
+ Verwerfen + Speichern +
+
+
+
INSTITUTION type: title, firstName, birthYear, deathYear hidden. "Nachname" relabeled to "Name". Same layout for GROUP type.
+
+
+ + +
+
Implementation Reference — Type Selector & Form Fields + Real values · mockup above is ~55% scale +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementTailwind classesReal sizeNotes
Type selector containerflex rounded-lg border border-line overflow-hidden mb-5h ~44px per buttonUse role="radiogroup", each button role="radio" with aria-checked. Arrow key navigation. Hidden <input type="hidden" name="personType"> holds the value.
Type button (inactive)flex-1 flex items-center justify-center gap-1.5 text-sm font-bold text-ink-3 bg-muted border-r border-line cursor-pointer py-2.514px / 700, py 10pxLast button: no border-r. Hover: bg-surface.
Type button (active)flex-1 flex items-center justify-center gap-1.5 text-sm font-bold text-primary-fg bg-primary py-2.514px / 700Active state replaces bg + text color. Icon inherits currentColor.
Type button iconw-4 h-416pxSame SVG paths as PersonTypeBadge + person silhouette for PERSON.
Title inputblock w-full max-w-[120px] rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ringmax-w 120px, py 8pxaria-label = i18n key for "Akademischer Titel". Only visible when type = PERSON.
Title labelmb-1 block text-xs font-bold tracking-widest text-ink-3 uppercase12px / 700Matches existing label pattern. Label text: paraglide key form_label_title.
Name row (PERSON)grid grid-cols-[120px_1fr_1fr] gap-4 md:grid-cols-[120px_1fr_1fr]gap 16pxMobile (<md): grid-cols-[80px_1fr] — title + firstName share row, lastName goes below full-width.
Name row (INSTITUTION/GROUP)grid grid-cols-1 gap-4full widthSingle "Name" field using the lastName input. Label changes via conditional: form_label_name i18n key.
Conditional visibilitySvelte {#if selectedType === 'PERSON'}Hide: title, firstName, birthYear, deathYear for INSTITUTION/GROUP. Hide: title, firstName, birthYear, deathYear, alias for UNKNOWN. Use $state for reactive type.
+
+
+ + + +
+
2 New Person Form
+ +
+ +
+
Default state — Person max-w-2xl changed
+
+
/persons/new
+
DokumentePersonen
+
+
Zurück zur Übersicht
+
Neue Person
+ +
+
Details
+ + +
+
+ + Person +
+
+ + Institution +
+
+ + Gruppe +
+
+ + Unbekannt +
+
+ + +
+
+
Titel
+
Dr.
+
+
+
Vorname *
+
Vorname
+
+
+
Nachname *
+
Nachname
+
+
+ +
+
+
Alias
+
z.B. Spitzname, Geburtsname…
+
+
+ +
+
+
Geburtsjahr
+
z.B. 1920
+
+
+
Sterbejahr
+
z.B. 1995
+
+
+ +
+
+
Notizen
+
Biografische Notizen…
+
+
+
+ +
+ Abbrechen + Erstellen +
+
+
+
New person form defaults to PERSON type. Same segmented control + title field as edit form. All fields empty with placeholders.
+
+ + +
+
Mobile — Person 375px responsive
+
+
9:41
+
+
+
Zurück
+
Neue Person
+ +
+
Details
+ + +
+
Person
+
Institution
+
Gruppe
+
Unbekannt
+
+ + +
+
+
Titel
+
Dr.
+
+
+
Vorname *
+
+
+
+
+
Nachname *
+
+
+
+
Alias
+
+
+
+
+
Geburtsjahr
+
+
+
+
Sterbejahr
+
+
+
+
+
Notizen
+
+
+
+ +
+ Abbrechen + Erstellen +
+
+
+
Mobile: type selector wraps to 2×2 grid. Title + firstName share a row (title ~80px, firstName fills rest). lastName goes full-width below.
+
+
+ + +
+
Implementation Reference — New Person Form + Real values · mockup above is ~55% scale +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementTailwind classesReal sizeNotes
Type selector (mobile)grid grid-cols-2 rounded-lg border border-line overflow-hidden mb-5min-h-[44px] per buttonBelow md breakpoint: 2×2 grid. Above md: single row (flex). Same role="radiogroup" pattern.
Title + firstName row (mobile)flex gap-4title w-[80px], firstName flex-1Below md: title shrinks to 80px. Above md: title gets 120px in the 3-col grid.
Default personTypelet selectedType = $state(person?.personType ?? 'PERSON')New person defaults to PERSON. Edit form uses existing value.
Hidden input<input type="hidden" name="personType" value={selectedType}>Submits type with the form. Read in +page.server.ts via formData.get('personType').
+
+
+ + + +
+
3 Person Detail Card — Title Display
+ +
+ +
+
Person with title changed
+
+
+
+
HR
+
Prof. Dr.
+
Heinrich Raddatz
+
„Der Professor“
+
1878 – 1944
+
+
Notizen
+ Professor der Germanistik in Leipzig. +
+
✎ Bearbeiten
+
+
+
Title shown as small-caps label above the serif display name. Color: text-ink-3.
+
+ + +
+
Person, no title unchanged look
+
+
+
+
MR
+
Martha Raddatz
+
1882 – 1961
+
✎ Bearbeiten
+
+
+
No title field → no label rendered. Layout collapses naturally.
+
+ + +
+
Institution unchanged look
+
+
+
+
🏛
+
Reichsfechtschule Leipzig
+
+ + Institution +
+
✎ Bearbeiten
+
+
+
Institutions never show a title line. PersonTypeBadge already renders.
+
+ + +
+
Group unchanged look
+
+
+
+
👥
+
Familie Müller
+
+ + Gruppe +
+
✎ Bearbeiten
+
+
+
Groups never show a title line either. Badge + icon avatar as before.
+
+
+ + +
+
Implementation Reference — PersonCard Title Display + Real values · mockup above is ~55% scale +
+ + + + + + + + + + + + + + + + + + + + + +
ElementTailwind classesReal sizeNotes
Title labeltext-xs font-bold uppercase tracking-widest text-ink-3 text-center12px / 700Only render when person.title is truthy AND personType === 'PERSON'. Placed between avatar and displayName.
Title spacingmb-0.52pxTight spacing to displayName below. When absent, avatar's mb-4 flows directly to name.
PersonCard proptitle?: string | null added to the person typeAlready in PersonSummaryDTO. Pass from +page.server.ts load.
+
+
+ + + +
+
4 Conditional Field Visibility Matrix
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldPersonInstitutionGroupUnknown
Title✓ visiblehiddenhiddenhidden
firstName✓ "Vorname"hiddenhiddenhidden
lastName✓ "Nachname"✓ "Name"✓ "Gruppenname"✓ "Name"
alias✓ visible✓ visible✓ visiblehidden
birthYear✓ "Geburtsjahr"hiddenhiddenhidden
deathYear✓ "Sterbejahr"hiddenhiddenhidden
notes✓ visible✓ visible✓ visible✓ visible
+
+ +
+ Key behavioral note: When switching type in the segmented control, hidden fields are not cleared — their values are preserved in case the user switches back. The backend already handles null/empty values gracefully. Only the personType value changes on submission. +
+ + +
+
Implementation Reference — Conditional Field Logic + Real values · Svelte reactivity +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ElementTailwind classesReal sizeNotes
Type statelet selectedType = $state<PersonType>('PERSON')Reactive state drives all conditionals. Type imported from generated API types.
showPersonFieldsconst showPersonFields = $derived(selectedType === 'PERSON')Controls title, firstName, birthYear, deathYear visibility.
showAliasconst showAlias = $derived(selectedType !== 'UNKNOWN')UNKNOWN hides alias (too little info to have one).
lastNameLabelconst lastNameLabel = $derived.by(() => { switch... })PERSON: form_label_last_name, INSTITUTION: form_label_name, GROUP: form_label_group_name, UNKNOWN: form_label_name. Needs 2 new i18n keys.
firstName requiredrequired attribute only when selectedType === 'PERSON'Remove required on firstName when hidden. lastName stays required always.
+
+
+ + + +
+
5 Accessibility Requirements
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RequirementWCAG CriterionImplementation
Segmented control semantics4.1.2 Name, Role, Valuerole="radiogroup" on container, role="radio" + aria-checked on each button. aria-label="Typ der Person" on container.
Keyboard navigation2.1.1 KeyboardArrow keys cycle through options. Tab moves out of the group. Space/Enter selects.
Touch target size2.5.8 Target Size (Minimum)Each segment button: min-h-[44px]. Mobile 2×2 grid: same minimum per cell.
Title input label1.3.1 Info and RelationshipsVisible <label for="title">. Also aria-describedby linking to help text if placeholder alone is ambiguous.
Hidden field announcement4.1.2 Name, Role, ValueWhen type changes, use aria-live="polite" region to announce which fields are now visible. E.g., "Felder für Person angezeigt".
Focus management2.4.3 Focus OrderTab order: type selector → title (if visible) → firstName (if visible) → lastName → alias (if visible) → birthYear (if visible) → deathYear (if visible) → notes.
+
+
+ + + +
+

Implementation Notes

+
+
+

Backend

+
    +
  • Add personType field to PersonUpdateDTO with @NotNull validation
  • +
  • PersonService.update(): set person.setPersonType(dto.getPersonType())
  • +
  • PersonService.create(): same — accept type from DTO
  • +
  • Regenerate OpenAPI spec + frontend types after DTO change
  • +
  • SKIP value: exclude from API validation (backend-only, used by import)
  • +
+
+
+

Frontend

+
    +
  • Refactor PersonEditForm.svelte: add selectedType state + segmented control
  • +
  • Add title input to name row (narrow, max-w-[120px])
  • +
  • Wrap conditional fields in {#if showPersonFields}
  • +
  • Update PersonCard.svelte prop type: add title
  • +
  • Update both +page.server.ts files: read/submit title + personType
  • +
  • New person form: inline the same type selector + title + conditionals
  • +
+
+
+

i18n Keys (new)

+
    +
  • form_label_title — "Titel" / "Title" / "Título"
  • +
  • form_label_name — "Name" / "Name" / "Nombre"
  • +
  • form_label_group_name — "Gruppenname" / "Group name" / "Nombre del grupo"
  • +
  • form_label_person_type — "Typ" / "Type" / "Tipo"
  • +
  • person_type_PERSON — "Person" / "Person" / "Persona"
  • +
  • a11y_type_fields_visible — "Felder für {type} angezeigt"
  • +
+
+
+
+ +
+ + -- 2.49.1 From 52dd72ae8dc0d57f8a11747af14ed2f8f2c59ad5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 12 Apr 2026 13:06:43 +0200 Subject: [PATCH 02/15] feat(i18n): add btn_confirm key to de/en/es message files Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + 3 files changed, 3 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 1bd5f8a2..53f8ed96 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -21,6 +21,7 @@ "nav_logout": "Abmelden", "btn_save": "Speichern", "btn_cancel": "Abbrechen", + "btn_confirm": "Bestätigen", "btn_edit": "Bearbeiten", "btn_create": "Erstellen", "btn_delete": "Löschen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4c247132..7c535417 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -21,6 +21,7 @@ "nav_logout": "Sign out", "btn_save": "Save", "btn_cancel": "Cancel", + "btn_confirm": "Confirm", "btn_edit": "Edit", "btn_create": "Create", "btn_delete": "Delete", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 89da4185..52502800 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -21,6 +21,7 @@ "nav_logout": "Cerrar sesión", "btn_save": "Guardar", "btn_cancel": "Cancelar", + "btn_confirm": "Confirmar", "btn_edit": "Editar", "btn_create": "Crear", "btn_delete": "Eliminar", -- 2.49.1 From fb00de66909f2ed5b575c6575f50521d567a085d Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 12 Apr 2026 13:07:46 +0200 Subject: [PATCH 03/15] feat(design-system): add --c-danger/--c-danger-fg token pair for destructive actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Light: #c0392b (5.1:1 on white — WCAG AA), dark: #e55347 (4.7:1 on surface). Exposed as bg-danger/text-danger-fg Tailwind utilities via @theme inline. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/layout.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css index c7c1bb46..d9b311e7 100644 --- a/frontend/src/routes/layout.css +++ b/frontend/src/routes/layout.css @@ -66,6 +66,10 @@ /* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */ --color-focus-ring: var(--c-focus-ring); + /* Danger — destructive action color */ + --color-danger: var(--c-danger); + --color-danger-fg: var(--c-danger-fg); + /* Static brand tokens (not themed) */ --color-brand-navy: var(--palette-navy); --color-brand-mint: var(--palette-mint); @@ -107,6 +111,10 @@ --c-pdf-ctrl: #d8d8d8; --c-pdf-text: #333333; + /* Danger — destructive actions (5.1:1 on white — WCAG AA ✓) */ + --c-danger: #c0392b; + --c-danger-fg: #ffffff; + /* PersonType badge — institution (navy-tinted blue) */ --c-badge-institution-bg: #e8eff7; --c-badge-institution-text: #1a4971; @@ -171,6 +179,10 @@ --c-badge-unknown-bg: rgba(122, 90, 10, 0.25); --c-badge-unknown-text: #e0c060; --c-badge-unknown-border: rgba(122, 90, 10, 0.4); + + /* Danger — destructive actions (4.7:1 on #011526 — WCAG AA ✓) */ + --c-danger: #e55347; + --c-danger-fg: #ffffff; } } @@ -219,6 +231,10 @@ --c-badge-unknown-bg: rgba(122, 90, 10, 0.25); --c-badge-unknown-text: #e0c060; --c-badge-unknown-border: rgba(122, 90, 10, 0.4); + + /* Danger — destructive actions (4.7:1 on #011526 — WCAG AA ✓) */ + --c-danger: #e55347; + --c-danger-fg: #ffffff; } /* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as ──── */ -- 2.49.1 From 1942c2a5cbb0e1207b83482a67883b41dea930b1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 12 Apr 2026 13:20:37 +0200 Subject: [PATCH 04/15] feat(confirm): add ConfirmService and ConfirmDialog with deferred-Promise pattern - confirm.svelte.ts: context-based async service returning Promise - ConfirmDialog.svelte: native element, reads service from context - Concurrent calls return false immediately (guard at top of confirm()) - SSR-safe: confirm() returns Promise.resolve(false) on server - getConfirmService() throws descriptive error outside provider tree - 5 Vitest tests: confirm/cancel/Escape/concurrent/outside-provider all green Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/ConfirmDialog.svelte | 61 +++++++++++ .../src/lib/services/confirm.svelte.test.ts | 70 ++++++++++++ frontend/src/lib/services/confirm.svelte.ts | 100 ++++++++++++++++++ .../src/lib/services/confirm.test-host.svelte | 11 ++ 4 files changed, 242 insertions(+) create mode 100644 frontend/src/lib/components/ConfirmDialog.svelte create mode 100644 frontend/src/lib/services/confirm.svelte.test.ts create mode 100644 frontend/src/lib/services/confirm.svelte.ts create mode 100644 frontend/src/lib/services/confirm.test-host.svelte diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 00000000..09d5af19 --- /dev/null +++ b/frontend/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,61 @@ + + + { + e.preventDefault(); + service.settle(false); + }} + onclick={(e) => { + const opts = service.options; + if (!opts) return; + const closeOnBackdrop = opts.closeOnBackdrop ?? !opts.destructive; + if (closeOnBackdrop && e.target === dialogEl) { + service.settle(false); + } + }} +> + {#if service.options} + {@const opts = service.options} +

{opts.title}

+ {#if opts.body !== undefined} +

{opts.body}

+ {/if} +
+ + +
+ {/if} +
diff --git a/frontend/src/lib/services/confirm.svelte.test.ts b/frontend/src/lib/services/confirm.svelte.test.ts new file mode 100644 index 00000000..a1449b96 --- /dev/null +++ b/frontend/src/lib/services/confirm.svelte.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import TestHost from './confirm.test-host.svelte'; +import type { ConfirmService } from './confirm.svelte.js'; + +afterEach(cleanup); + +function makeHost(): { service: ConfirmService } { + const result: { service: ConfirmService | null } = { service: null }; + render(TestHost, { + onReady: (s: ConfirmService) => { + result.service = s; + } + }); + return result as { service: ConfirmService }; +} + +describe('ConfirmService', () => { + it('resolves true when the user clicks Confirm', async () => { + const { service } = makeHost(); + + const resultPromise = service.confirm({ title: 'Test?' }); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + await page.getByRole('button', { name: 'Bestätigen' }).click(); + + expect(await resultPromise).toBe(true); + }); + + it('resolves false when the user clicks Cancel', async () => { + const { service } = makeHost(); + + const resultPromise = service.confirm({ title: 'Test?' }); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + await page.getByRole('button', { name: 'Abbrechen' }).click(); + + expect(await resultPromise).toBe(false); + }); + + it('resolves false when Escape is pressed', async () => { + const { service } = makeHost(); + + const resultPromise = service.confirm({ title: 'Test?' }); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + await userEvent.keyboard('{Escape}'); + + expect(await resultPromise).toBe(false); + }); + + it('resolves false immediately on a concurrent call while dialog is open', async () => { + const { service } = makeHost(); + + const first = service.confirm({ title: 'First?' }); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + + const second = service.confirm({ title: 'Second?' }); + expect(await second).toBe(false); + + // clean up the first dialog + await page.getByRole('button', { name: 'Abbrechen' }).click(); + expect(await first).toBe(false); + }); + + it('throws a descriptive error when called outside provider tree', async () => { + const { getConfirmService } = await import('./confirm.svelte.js'); + // Outside component init, getContext throws or returns undefined — our guard + // converts either case to a descriptive developer error + expect(() => getConfirmService()).toThrow('mount '); + }); +}); diff --git a/frontend/src/lib/services/confirm.svelte.ts b/frontend/src/lib/services/confirm.svelte.ts new file mode 100644 index 00000000..28bbf435 --- /dev/null +++ b/frontend/src/lib/services/confirm.svelte.ts @@ -0,0 +1,100 @@ +/** + * Context-based confirmation service. Provides an async `confirm()` function + * that any component can call without managing its own modal state. + * + * ## Setup + * Mount `` once in the root `+layout.svelte` — it sets up the context + * automatically. Then call `getConfirmService()` from any descendant component. + * + * ## Usage in event handlers + * ```typescript + * import { getConfirmService } from '$lib/services/confirm.svelte.js'; + * const { confirm } = getConfirmService(); + * + * async function handleDelete() { + * const ok = await confirm({ title: m.confirm_delete_title(), destructive: true }); + * if (ok) doDelete(); + * } + * ``` + * + * ## Usage with use:enhance + * ```svelte + *
{ + * const ok = await confirm({ title: m.confirm_delete_title(), destructive: true }); + * if (!ok) cancel(); + * }}> + * ``` + */ +import { getContext, setContext } from 'svelte'; +import { browser } from '$app/environment'; + +export const CONFIRM_KEY = Symbol('confirm'); + +export interface ConfirmOptions { + title: string; + body?: string; + /** Defaults to m.btn_confirm() ("Bestätigen") */ + confirmLabel?: string; + /** Defaults to m.btn_cancel() ("Abbrechen") */ + cancelLabel?: string; + /** Uses danger color for confirm button. Defaults to false. */ + destructive?: boolean; + /** Close when clicking outside the dialog. Defaults to !destructive. */ + closeOnBackdrop?: boolean; +} + +export interface ConfirmService { + confirm(opts: ConfirmOptions): Promise; + /** Read by ConfirmDialog to render the current dialog. Internal use only. */ + readonly options: ConfirmOptions | null; + /** Called by ConfirmDialog when the user makes a choice. Internal use only. */ + settle(value: boolean): void; +} + +export function createConfirmService(): ConfirmService { + let resolveRef: ((value: boolean) => void) | null = $state(null); + let options: ConfirmOptions | null = $state(null); + + return { + confirm(opts: ConfirmOptions): Promise { + if (!browser) return Promise.resolve(false); + // Concurrent call while dialog is already open — reject immediately. + if (resolveRef !== null) return Promise.resolve(false); + options = opts; + return new Promise((r) => { + resolveRef = r; + }); + }, + + get options() { + return options; + }, + + settle(value: boolean): void { + options = null; + const r = resolveRef; + resolveRef = null; + r?.(value); + } + }; +} + +export function provideConfirmService(): ConfirmService { + const service = createConfirmService(); + setContext(CONFIRM_KEY, service); + return service; +} + +export function getConfirmService(): ConfirmService { + // Outside component init, getContext either returns undefined or throws a Svelte error. + // Either way, map it to our descriptive developer error. + let service: ConfirmService | undefined; + try { + service = getContext(CONFIRM_KEY); + } catch { + throw new Error('ConfirmService not found — mount in +layout.svelte'); + } + if (!service) + throw new Error('ConfirmService not found — mount in +layout.svelte'); + return service; +} diff --git a/frontend/src/lib/services/confirm.test-host.svelte b/frontend/src/lib/services/confirm.test-host.svelte new file mode 100644 index 00000000..0d6a8406 --- /dev/null +++ b/frontend/src/lib/services/confirm.test-host.svelte @@ -0,0 +1,11 @@ + + + -- 2.49.1 From 08bd27b5cd060a1b32481322f9a955a0999f6ef4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 12 Apr 2026 13:21:34 +0200 Subject: [PATCH 05/15] feat(layout): mount ConfirmDialog in root layout and provide confirm service provideConfirmService() sets up context for the entire component tree. ConfirmDialog is mounted once at the bottom of the layout shell. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+layout.svelte | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c25a1abe..3aada446 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -7,9 +7,15 @@ import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import NotificationBell from '$lib/components/NotificationBell.svelte'; import AppNav from './AppNav.svelte'; import UserMenu from './UserMenu.svelte'; +import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; +import { provideConfirmService } from '$lib/services/confirm.svelte.js'; let { children, data } = $props(); +// Provide the confirmation service to the entire component tree. +// ConfirmDialog below reads it via getConfirmService() and renders the . +provideConfirmService(); + const isAdmin = $derived( data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')) ); @@ -70,4 +76,7 @@ const userInitials = $derived.by(() => {
{@render children()}
+ + + -- 2.49.1 From d4ead08c17a1e766a23b8f3652c4a6edb52be42e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 12 Apr 2026 13:47:37 +0200 Subject: [PATCH 06/15] refactor(transcription): replace window.confirm with ConfirmService in TranscriptionBlock Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/TranscriptionBlock.svelte | 13 ++- .../TranscriptionBlock.svelte.spec.ts | 93 +++++++++++++------ .../TranscriptionBlock.test-host.svelte | 36 +++++++ 3 files changed, 109 insertions(+), 33 deletions(-) create mode 100644 frontend/src/lib/components/TranscriptionBlock.test-host.svelte diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 3da162b5..e0b3b7bd 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -1,7 +1,10 @@ + + -- 2.49.1 From 0d1401ce4f0b7109a768f266be44e6e1e3426d1f Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 12 Apr 2026 14:04:09 +0200 Subject: [PATCH 07/15] refactor(admin): replace window.confirm with ConfirmService in admin user delete Co-Authored-By: Claude Sonnet 4.6 --- .../TranscriptionEditView.svelte.spec.ts | 51 +++++----- .../src/routes/admin/users/[id]/+page.svelte | 28 +++--- .../admin/users/[id]/page.svelte.spec.ts | 93 ++++++++++++++----- 3 files changed, 117 insertions(+), 55 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts index 11a4d29b..d537de83 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import TranscriptionEditView from './TranscriptionEditView.svelte'; +import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js'; afterEach(cleanup); @@ -24,17 +25,23 @@ const block2 = { version: 0 }; -function renderView(overrides: Record = {}) { - return render(TranscriptionEditView, { - documentId: 'doc-1', - blocks: [block1, block2], - canComment: true, - currentUserId: 'user-1', - onBlockFocus: vi.fn(), - onSaveBlock: vi.fn(), - onDeleteBlock: vi.fn(), - ...overrides - }); +function renderView(overrides: Record = {}, service = createConfirmService()) { + return { + ...render(TranscriptionEditView, { + props: { + documentId: 'doc-1', + blocks: [block1, block2], + canComment: true, + currentUserId: 'user-1', + onBlockFocus: vi.fn(), + onSaveBlock: vi.fn(), + onDeleteBlock: vi.fn(), + ...overrides + }, + context: new Map([[CONFIRM_KEY, service]]) + }), + service + }; } describe('TranscriptionEditView — rendering', () => { @@ -200,25 +207,27 @@ describe('TranscriptionEditView — flush on blur', () => { describe('TranscriptionEditView — delete block', () => { it('calls onDeleteBlock with correct blockId when delete is confirmed', async () => { const onDeleteBlock = vi.fn().mockResolvedValue(undefined); - vi.spyOn(window, 'confirm').mockReturnValue(true); - renderView({ onDeleteBlock }); + const { service } = renderView({ onDeleteBlock }); - const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); - await deleteBtn.click(); + const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement; + deleteBtn.click(); + await vi.waitFor(() => expect(service.options).not.toBeNull()); + service.settle(true); + await vi.waitFor(() => expect(service.options).toBeNull()); expect(onDeleteBlock).toHaveBeenCalledWith('b1'); - vi.restoreAllMocks(); }); it('does not call onDeleteBlock when deletion is cancelled', async () => { const onDeleteBlock = vi.fn(); - vi.spyOn(window, 'confirm').mockReturnValue(false); - renderView({ onDeleteBlock }); + const { service } = renderView({ onDeleteBlock }); - const deleteBtn = page.getByRole('button', { name: 'Löschen' }).first(); - await deleteBtn.click(); + const deleteBtn = document.querySelector('button[aria-label="Löschen"]') as HTMLButtonElement; + deleteBtn.click(); + await vi.waitFor(() => expect(service.options).not.toBeNull()); + service.settle(false); + await vi.waitFor(() => expect(service.options).toBeNull()); expect(onDeleteBlock).not.toHaveBeenCalled(); - vi.restoreAllMocks(); }); }); diff --git a/frontend/src/routes/admin/users/[id]/+page.svelte b/frontend/src/routes/admin/users/[id]/+page.svelte index 377a9864..c0e8f8db 100644 --- a/frontend/src/routes/admin/users/[id]/+page.svelte +++ b/frontend/src/routes/admin/users/[id]/+page.svelte @@ -2,17 +2,29 @@ import { enhance } from '$app/forms'; import { beforeNavigate, goto } from '$app/navigation'; import { m } from '$lib/paraglide/messages.js'; +import { getConfirmService } from '$lib/services/confirm.svelte.js'; import UserProfileSection from '$lib/components/user/UserProfileSection.svelte'; import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte'; import UserPasswordSection from '$lib/components/user/UserPasswordSection.svelte'; let { data, form } = $props(); +const { confirm } = getConfirmService(); + const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []); let isDirty = $state(false); let showUnsavedWarning = $state(false); let discardTarget: string | null = $state(null); +let deleteFormEl = $state(null); + +async function handleDelete() { + const confirmed = await confirm({ + title: m.admin_user_delete_confirm({ username: data.editUser.username }), + destructive: true + }); + if (confirmed) deleteFormEl!.requestSubmit(); +} beforeNavigate(({ cancel, to }) => { if (isDirty) { @@ -51,20 +63,10 @@ $effect(() => {

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

- { - if (!confirm(m.admin_user_delete_confirm({ username: data.editUser.username }))) { - cancel(); - } - return async ({ update }) => { - await update(); - }; - }} - > + - - - {:else} - - - {m.btn_cancel()} - - {/if} + + + + + + {m.btn_delete()} + + + {m.btn_cancel()} + diff --git a/frontend/src/routes/documents/[id]/edit/SaveBar.svelte.spec.ts b/frontend/src/routes/documents/[id]/edit/SaveBar.svelte.spec.ts new file mode 100644 index 00000000..d1ad0d97 --- /dev/null +++ b/frontend/src/routes/documents/[id]/edit/SaveBar.svelte.spec.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import SaveBar from './SaveBar.svelte'; +import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js'; + +let appendedForms: HTMLFormElement[] = []; + +afterEach(() => { + cleanup(); + appendedForms.forEach((f) => f.remove()); + appendedForms = []; +}); + +function renderSaveBar(docId = 'doc-1') { + const service = createConfirmService(); + + // Mount a dummy delete form so SaveBar can find it via document.getElementById + const deleteForm = document.createElement('form'); + deleteForm.id = 'delete-form'; + document.body.appendChild(deleteForm); + appendedForms.push(deleteForm); + + const result = render(SaveBar, { + props: { docId }, + context: new Map([[CONFIRM_KEY, service]]) + }); + + return { ...result, service, deleteForm }; +} + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('SaveBar — rendering', () => { + it('renders save button', async () => { + renderSaveBar(); + await expect.element(page.getByRole('button', { name: /Speichern/i })).toBeInTheDocument(); + }); + + it('renders delete button', async () => { + renderSaveBar(); + // The delete button should be type="button" (async confirm flow) + const deleteBtn = document.querySelector('button[type="button"]'); + expect(deleteBtn).not.toBeNull(); + }); + + it('renders cancel link pointing to /documents/doc-1', async () => { + renderSaveBar(); + await expect + .element(page.getByRole('link', { name: /Abbrechen/i })) + .toHaveAttribute('href', '/documents/doc-1'); + }); +}); + +// ─── Delete confirmation ────────────────────────────────────────────────────── + +describe('SaveBar — delete confirmation', () => { + it('opens confirm dialog when delete button is clicked', async () => { + const { service } = renderSaveBar(); + const deleteBtn = document.querySelectorAll('button[type="button"]')[0]; + deleteBtn.click(); + await vi.waitFor(() => expect(service.options).not.toBeNull()); + expect(service.options?.destructive).toBe(true); + service.settle(false); + }); + + it('submits delete form when user confirms', async () => { + const { service, deleteForm } = renderSaveBar(); + const requestSubmit = vi.spyOn(deleteForm, 'requestSubmit').mockImplementation(() => {}); + + const deleteBtn = document.querySelectorAll('button[type="button"]')[0]; + deleteBtn.click(); + await vi.waitFor(() => expect(service.options).not.toBeNull()); + service.settle(true); + await vi.waitFor(() => expect(service.options).toBeNull()); + + expect(requestSubmit).toHaveBeenCalledOnce(); + }); + + it('does not submit delete form when user cancels', async () => { + const { service, deleteForm } = renderSaveBar(); + const requestSubmit = vi.spyOn(deleteForm, 'requestSubmit').mockImplementation(() => {}); + + const deleteBtn = document.querySelectorAll('button[type="button"]')[0]; + deleteBtn.click(); + await vi.waitFor(() => expect(service.options).not.toBeNull()); + service.settle(false); + await vi.waitFor(() => expect(service.options).toBeNull()); + + expect(requestSubmit).not.toHaveBeenCalled(); + }); +}); -- 2.49.1 From 1a519eedd62589083da258ab5851f854d14cc20e Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 12 Apr 2026 14:19:33 +0200 Subject: [PATCH 10/15] refactor(persons): replace inline delete modal with ConfirmService in NameHistoryEditCard Co-Authored-By: Claude Sonnet 4.6 --- .../[id]/edit/NameHistoryEditCard.svelte | 67 ++++------ .../edit/NameHistoryEditCard.svelte.spec.ts | 118 ++++++++++++++++++ .../edit/NameHistoryEditCard.svelte.test.ts | 56 ++++++--- 3 files changed, 175 insertions(+), 66 deletions(-) create mode 100644 frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte.spec.ts diff --git a/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte index a7e6c185..0273b5df 100644 --- a/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte +++ b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte @@ -1,6 +1,7 @@ @@ -65,7 +75,7 @@ function confirmDelete(id: string) { {#if canWrite} -
{ - return async ({ update }) => { - showDeleteModal = false; - deleteTargetId = null; - await update(); - }; - }} - > - - -
- - - -{/if} diff --git a/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte.spec.ts b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte.spec.ts new file mode 100644 index 00000000..271cf4aa --- /dev/null +++ b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import NameHistoryEditCard from './NameHistoryEditCard.svelte'; +import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); + +afterEach(cleanup); + +const aliases = [ + { id: 'a1', lastName: 'Müller', firstName: 'Anna', type: 'BIRTH', sortOrder: 0 }, + { id: 'a2', lastName: 'Schmidt', firstName: null, type: 'MARRIED', sortOrder: 1 } +]; + +function renderCard(overrides: Record = {}) { + const service = createConfirmService(); + const result = render(NameHistoryEditCard, { + props: { aliases, canWrite: true, ...overrides }, + context: new Map([[CONFIRM_KEY, service]]) + }); + return { ...result, service }; +} + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('NameHistoryEditCard — rendering', () => { + it('renders alias last names', async () => { + renderCard(); + await expect.element(page.getByText('Müller')).toBeInTheDocument(); + await expect.element(page.getByText('Schmidt')).toBeInTheDocument(); + }); + + it('renders delete buttons when canWrite', async () => { + renderCard(); + const btns = document.querySelectorAll('button[type="button"]'); + expect(btns.length).toBeGreaterThanOrEqual(2); + }); + + it('does not render delete buttons when canWrite is false', async () => { + renderCard({ canWrite: false }); + const btns = document.querySelectorAll('button[type="button"]'); + expect(btns.length).toBe(0); + }); + + it('does not show the inline delete modal (replaced by ConfirmService)', async () => { + renderCard(); + // The old inline modal div with "fixed inset-0" should not exist + const modal = document.querySelector('.fixed.inset-0'); + expect(modal).toBeNull(); + }); +}); + +// ─── Delete confirmation ────────────────────────────────────────────────────── + +describe('NameHistoryEditCard — delete confirmation', () => { + it('opens confirm dialog when delete button is clicked', async () => { + const { service } = renderCard(); + const deleteBtn = document.querySelector('button[type="button"]')!; + deleteBtn.click(); + await vi.waitFor(() => expect(service.options).not.toBeNull()); + expect(service.options?.destructive).toBe(true); + service.settle(false); + }); + + it('submits removeAlias form when user confirms', async () => { + const { service } = renderCard(); + const requestSubmit = vi + .spyOn(HTMLFormElement.prototype, 'requestSubmit') + .mockImplementation(() => {}); + + const deleteBtn = document.querySelector('button[type="button"]')!; + deleteBtn.click(); + await vi.waitFor(() => expect(service.options).not.toBeNull()); + service.settle(true); + + // Wait for the async handleDelete callback to call requestSubmit + await vi.waitFor(() => expect(requestSubmit).toHaveBeenCalledOnce()); + requestSubmit.mockRestore(); + }); + + it('does not submit form when user cancels', async () => { + const { service } = renderCard(); + const requestSubmit = vi + .spyOn(HTMLFormElement.prototype, 'requestSubmit') + .mockImplementation(() => {}); + + const deleteBtn = document.querySelector('button[type="button"]')!; + deleteBtn.click(); + await vi.waitFor(() => expect(service.options).not.toBeNull()); + service.settle(false); + await vi.waitFor(() => expect(service.options).toBeNull()); + + // Allow pending microtasks to flush before asserting + await Promise.resolve(); + expect(requestSubmit).not.toHaveBeenCalled(); + requestSubmit.mockRestore(); + }); + + it('submits with the correct aliasId when second alias is deleted', async () => { + const { service } = renderCard(); + const requestSubmit = vi + .spyOn(HTMLFormElement.prototype, 'requestSubmit') + .mockImplementation(() => {}); + + // Click delete button for the second alias (Schmidt) + const deleteBtns = document.querySelectorAll('button[type="button"]'); + deleteBtns[1].click(); + await vi.waitFor(() => expect(service.options).not.toBeNull()); + service.settle(true); + + await vi.waitFor(() => expect(requestSubmit).toHaveBeenCalledOnce()); + const submittedForm = requestSubmit.mock.instances[0] as HTMLFormElement; + const aliasIdInput = submittedForm.querySelector('input[name="aliasId"]'); + expect(aliasIdInput?.value).toBe('a2'); + requestSubmit.mockRestore(); + }); +}); diff --git a/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte.test.ts b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte.test.ts index ce5510cd..1073aed2 100644 --- a/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte.test.ts +++ b/frontend/src/routes/persons/[id]/edit/NameHistoryEditCard.svelte.test.ts @@ -1,84 +1,100 @@ -import { describe, it, expect } from 'vitest'; -import { render } from 'vitest-browser-svelte'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import NameHistoryEditCard from './NameHistoryEditCard.svelte'; +import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js'; + +vi.mock('$app/forms', () => ({ enhance: () => () => {} })); + +afterEach(cleanup); const aliases = [ { id: 'a1', lastName: 'de Gruyter', firstName: null, type: 'BIRTH', sortOrder: 0 }, { id: 'a2', lastName: 'Schmidt', firstName: 'Maria', type: 'WIDOWED', sortOrder: 1 } ]; +function renderCard(props: Record = {}) { + const service = createConfirmService(); + return { + ...render(NameHistoryEditCard, { + props: { aliases, canWrite: true, ...props }, + context: new Map([[CONFIRM_KEY, service]]) + }), + service + }; +} + describe('NameHistoryEditCard', () => { it('should render alias rows when aliases exist', async () => { - render(NameHistoryEditCard, { aliases, canWrite: true }); + renderCard(); await expect.element(page.getByText('de Gruyter')).toBeInTheDocument(); await expect.element(page.getByText('Schmidt')).toBeInTheDocument(); }); it('should show empty state when no aliases', async () => { - render(NameHistoryEditCard, { aliases: [], canWrite: true }); + renderCard({ aliases: [] }); const emptyText = document.querySelector('.italic'); expect(emptyText).not.toBeNull(); }); it('should show add form when canWrite is true', async () => { - render(NameHistoryEditCard, { aliases: [], canWrite: true }); + renderCard({ aliases: [] }); const form = document.querySelector('form[action="?/addAlias"]'); expect(form).not.toBeNull(); }); it('should hide add form when canWrite is false', async () => { - render(NameHistoryEditCard, { aliases: [], canWrite: false }); + renderCard({ aliases: [], canWrite: false }); const form = document.querySelector('form[action="?/addAlias"]'); expect(form).toBeNull(); }); it('should hide delete buttons when canWrite is false', async () => { - render(NameHistoryEditCard, { aliases, canWrite: false }); + renderCard({ canWrite: false }); const deleteButtons = document.querySelectorAll('button[aria-label*="Entfernen"]'); expect(deleteButtons.length).toBe(0); }); it('should show delete buttons when canWrite is true', async () => { - render(NameHistoryEditCard, { aliases, canWrite: true }); + renderCard(); const deleteButtons = document.querySelectorAll('button[aria-label*="Entfernen"]'); expect(deleteButtons.length).toBe(2); }); it('should include alias name in delete button aria-label', async () => { - render(NameHistoryEditCard, { aliases: [aliases[0]], canWrite: true }); + renderCard({ aliases: [aliases[0]] }); const btn = document.querySelector('button[aria-label*="de Gruyter"]'); expect(btn).not.toBeNull(); }); - it('should show delete modal when delete button is clicked', async () => { - render(NameHistoryEditCard, { aliases: [aliases[0]], canWrite: true }); + it('should open ConfirmService dialog (not inline modal) when delete button is clicked', async () => { + const { service } = renderCard({ aliases: [aliases[0]] }); - const deleteBtn = document.querySelector('button[aria-label*="de Gruyter"]')!; - deleteBtn.dispatchEvent(new MouseEvent('click', { bubbles: true })); + const deleteBtn = document.querySelector( + 'button[aria-label*="de Gruyter"]' + ) as HTMLButtonElement; + deleteBtn.click(); - await expect.element(page.getByText('Alias entfernen?')).toBeInTheDocument(); + await vi.waitFor(() => expect(service.options).not.toBeNull()); + expect(service.options?.destructive).toBe(true); + service.settle(false); }); it('should show alias error when provided', async () => { - render(NameHistoryEditCard, { - aliases: [], - canWrite: true, - aliasError: 'Something went wrong' - }); + renderCard({ aliases: [], aliasError: 'Something went wrong' }); await expect.element(page.getByText('Something went wrong')).toBeInTheDocument(); }); it('should have required attribute on lastName input', async () => { - render(NameHistoryEditCard, { aliases: [], canWrite: true }); + renderCard({ aliases: [] }); const input = document.querySelector('input[name="lastName"]') as HTMLInputElement; expect(input.required).toBe(true); -- 2.49.1 From 3a316bc38272a58f2a759f18ccf9dd873566981c Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 12 Apr 2026 14:33:33 +0200 Subject: [PATCH 11/15] fix(ui): center dialog, add backdrop, hover states, and cursor-pointer on buttons - Add m-auto and w-full to ensure the native is centred - Add backdrop:bg-black/50 for dimmed overlay when modal is open - Add hover:bg-danger/80 and hover:bg-primary/80 on confirm button - Add cursor-pointer to both cancel and confirm buttons Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/ConfirmDialog.svelte | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte index 09d5af19..66f7e28a 100644 --- a/frontend/src/lib/components/ConfirmDialog.svelte +++ b/frontend/src/lib/components/ConfirmDialog.svelte @@ -18,7 +18,7 @@ $effect(() => { { e.preventDefault(); @@ -42,16 +42,16 @@ $effect(() => {