fix(journey): editor review round — labels, errors, pending state, a11y, tests

Addresses the remaining #792 review blockers and concerns in the journey
editor cluster:

- Interlude rows show 'Zwischentext' (dedicated key), not the add-button text
- All four mutation handlers route the backend ErrorCode through
  getErrorMessage (a 409 duplicate no longer says 'bitte Seite neu laden')
  and console.error their failures so client-side errors leave a trace
- Remove implements the spec'd pending state: row stays dimmed with an
  aria-live 'wird entfernt…' until the DELETE resolves; failure keeps the row
- Move announcements fire after the reorder resolves (no false 'verschoben')
- Touch targets ≥44px (remove ×, note links, create submit); focus moves to
  the new row after add, to a sensible neighbor after remove, back to × on
  confirm-cancel; drag handle is pointer-only; title/intro get aria-labels;
  publish-disabled reason is a visible hint, not a title tooltip
- Amber warning styles use new --color-warning-* tokens with dark remaps
- Blocked interlude-clear restores the draft instead of showing phantom text
- useBlockDragDrop moves to $lib/shared/hooks — geschichte no longer imports
  another domain's internals
- Test hardening: reorder-failure rollback (non-ok + reject), publish/
  unpublish/empty-warning surface, destructive confirm path, maxlength
  assertions, JourneyCreate failure path, edit-page STORY/JOURNEY branch,
  fixture factory, m.* assertions, all fixed sleeps replaced with polling

67 component tests green across 6 spec files; transcription consumer of the
moved hook re-verified (30 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-10 07:55:12 +02:00
parent 7977d22d0b
commit f10b0cb73e
13 changed files with 843 additions and 268 deletions

View File

@@ -5,7 +5,7 @@ import OcrTrigger from '$lib/ocr/OcrTrigger.svelte';
import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte'; import TranscribeCoachEmptyState from '$lib/shared/help/TranscribeCoachEmptyState.svelte';
import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types'; import type { PersonMention, TranscriptionBlockData } from '$lib/shared/types';
import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte'; import { createBlockAutoSave } from '$lib/document/transcription/useBlockAutoSave.svelte';
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte'; import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
import { csrfFetch } from '$lib/shared/cookies'; import { csrfFetch } from '$lib/shared/cookies';
type Props = { type Props = {

View File

@@ -40,6 +40,7 @@ function handleInterludeCancel() {
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
type="button" type="button"
data-add-document
onclick={() => { onclick={() => {
showPicker = !showPicker; showPicker = !showPicker;
showInterludeForm = false; showInterludeForm = false;

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyAddBar from './JourneyAddBar.svelte'; import JourneyAddBar from './JourneyAddBar.svelte';
afterEach(() => { afterEach(() => {
@@ -12,19 +13,25 @@ describe('JourneyAddBar — interlude flow', () => {
it('interlude confirm button is natively disabled when text is empty (WCAG 4.1.2)', async () => { it('interlude confirm button is natively disabled when text is empty (WCAG 4.1.2)', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText('Zwischentext hinzufügen')); await userEvent.click(page.getByText(m.journey_add_interlude()));
const confirmBtn = page.getByRole('button', { name: 'Hinzufügen', exact: true }); const confirmBtn = page.getByRole('button', {
name: m.journey_add_interlude_confirm(),
exact: true
});
await expect.element(confirmBtn).toBeDisabled(); await expect.element(confirmBtn).toBeDisabled();
}); });
it('confirm becomes enabled after typing text', async () => { it('confirm becomes enabled after typing text', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText('Zwischentext hinzufügen')); await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise'); await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise');
const confirmBtn = page.getByRole('button', { name: 'Hinzufügen', exact: true }); const confirmBtn = page.getByRole('button', {
name: m.journey_add_interlude_confirm(),
exact: true
});
await expect.element(confirmBtn).toBeEnabled(); await expect.element(confirmBtn).toBeEnabled();
}); });
@@ -32,12 +39,22 @@ describe('JourneyAddBar — interlude flow', () => {
const onAddInterlude = vi.fn(); const onAddInterlude = vi.fn();
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude }); render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude });
await userEvent.click(page.getByText('Zwischentext hinzufügen')); await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien'); await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien');
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true })); await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien'); expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien');
}); });
it('limits the interlude textarea to 2000 characters', async () => {
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText(m.journey_add_interlude()));
await expect.element(page.getByRole('textbox')).toHaveAttribute('maxlength', '2000');
});
}); });
describe('JourneyAddBar — document picker', () => { describe('JourneyAddBar — document picker', () => {
@@ -48,7 +65,7 @@ describe('JourneyAddBar — document picker', () => {
); );
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() }); render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
await userEvent.click(page.getByText('Brief hinzufügen')); await userEvent.click(page.getByText(m.journey_add_document()));
await expect.element(page.getByRole('combobox')).toBeInTheDocument(); await expect.element(page.getByRole('combobox')).toBeInTheDocument();
}); });

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { csrfFetch } from '$lib/shared/cookies'; import { csrfFetch } from '$lib/shared/cookies';
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte'; import { getErrorMessage } from '$lib/shared/errors';
import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
import { toPersonOption, type PersonOption } from '$lib/person/personOption'; import { toPersonOption, type PersonOption } from '$lib/person/personOption';
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte'; import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
import type { DocumentOption } from '$lib/document/documentTypeahead'; import type { DocumentOption } from '$lib/document/documentTypeahead';
@@ -40,6 +42,7 @@ let items: JourneyItemView[] = $state(
let titleTouched = $state(false); let titleTouched = $state(false);
let mutationError = $state(''); let mutationError = $state('');
let pendingRemoveIds: string[] = $state([]);
let liveAnnounce = $state(''); let liveAnnounce = $state('');
let announceTimer: ReturnType<typeof setTimeout> | null = null; let announceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -61,6 +64,7 @@ const canPublish = $derived(items.length > 0 && !titleEmpty);
const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0); const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0);
let listEl: HTMLElement | null = $state(null); let listEl: HTMLElement | null = $state(null);
let editorColEl: HTMLElement | null = $state(null);
const dragDrop = createBlockDragDrop<JourneyItemView>({ const dragDrop = createBlockDragDrop<JourneyItemView>({
getSortedBlocks: () => items, getSortedBlocks: () => items,
@@ -71,6 +75,18 @@ $effect(() => {
dragDrop.setListElement(listEl); dragDrop.setListElement(listEl);
}); });
/** Maps a failed mutation response to a user-facing message via its backend error code. */
async function failureMessage(res: Response): Promise<string> {
const code = (await res.json().catch(() => ({})))?.code;
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
}
/** Moves keyboard focus to a control inside the row of the given item. */
async function focusRowControl(itemId: string, selector: string) {
await tick();
editorColEl?.querySelector<HTMLElement>(`[data-block-id="${itemId}"] ${selector}`)?.focus();
}
async function handleReorder(itemIds: string[]) { async function handleReorder(itemIds: string[]) {
const prev = [...items]; const prev = [...items];
items = itemIds.map((id) => items.find((i) => i.id === id)!); items = itemIds.map((id) => items.find((i) => i.id === id)!);
@@ -81,17 +97,21 @@ async function handleReorder(itemIds: string[]) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemIds }) body: JSON.stringify({ itemIds })
}); });
if (!res.ok) throw new Error('reorder failed'); if (!res.ok) {
items = prev;
mutationError = await failureMessage(res);
return;
}
const updated: JourneyItemView[] = await res.json(); const updated: JourneyItemView[] = await res.json();
items = updated.sort((a, b) => a.position - b.position); items = updated.sort((a, b) => a.position - b.position);
} catch { } catch (e) {
console.error('Journey reorder failed', e);
items = prev; items = prev;
mutationError = m.journey_mutation_error_reload(); mutationError = m.journey_mutation_error_reload();
} }
} }
async function handleAddDocument(doc: DocumentOption) { async function handleAddDocument(doc: DocumentOption) {
const prev = [...items];
mutationError = ''; mutationError = '';
try { try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, { const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
@@ -99,17 +119,21 @@ async function handleAddDocument(doc: DocumentOption) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId: doc.id }) body: JSON.stringify({ documentId: doc.id })
}); });
if (!res.ok) throw new Error('add document failed'); if (!res.ok) {
mutationError = await failureMessage(res);
return;
}
const newItem: JourneyItemView = await res.json(); const newItem: JourneyItemView = await res.json();
items = [...items, newItem]; items = [...items, newItem];
} catch { // Move-up is disabled on the first row — fall back to the remove button then.
items = prev; // prev === items here (add is pessimistic); kept for symmetry with optimistic handlers await focusRowControl(newItem.id, '[data-move-up]:not([disabled]), [data-remove-btn]');
} catch (e) {
console.error('Journey add document failed', e);
mutationError = m.journey_mutation_error_reload(); mutationError = m.journey_mutation_error_reload();
} }
} }
async function handleAddInterlude(text: string) { async function handleAddInterlude(text: string) {
const prev = [...items];
mutationError = ''; mutationError = '';
try { try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, { const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
@@ -117,27 +141,46 @@ async function handleAddInterlude(text: string) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: text }) body: JSON.stringify({ note: text })
}); });
if (!res.ok) throw new Error('add interlude failed'); if (!res.ok) {
mutationError = await failureMessage(res);
return;
}
const newItem: JourneyItemView = await res.json(); const newItem: JourneyItemView = await res.json();
items = [...items, newItem]; items = [...items, newItem];
} catch { // Move-up is disabled on the first row — fall back to the remove button then.
items = prev; // prev === items here (add is pessimistic); kept for symmetry with optimistic handlers await focusRowControl(newItem.id, '[data-move-up]:not([disabled]), [data-remove-btn]');
} catch (e) {
console.error('Journey add interlude failed', e);
mutationError = m.journey_mutation_error_reload(); mutationError = m.journey_mutation_error_reload();
} }
} }
async function handleRemove(itemId: string) { async function handleRemove(itemId: string) {
const prev = [...items]; const idx = items.findIndex((i) => i.id === itemId);
items = items.filter((i) => i.id !== itemId);
mutationError = ''; mutationError = '';
pendingRemoveIds = [...pendingRemoveIds, itemId];
liveAnnounce = m.journey_item_pending_remove();
scheduleAnnounceReset();
try { try {
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, { const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (!res.ok) throw new Error('delete failed'); if (!res.ok) {
} catch { mutationError = await failureMessage(res);
items = prev; return;
}
items = items.filter((i) => i.id !== itemId);
await tick();
if (items.length === 0 || idx <= 0) {
editorColEl?.querySelector<HTMLElement>('[data-add-document]')?.focus();
} else {
await focusRowControl(items[idx - 1].id, '[data-remove-btn]');
}
} catch (e) {
console.error('Journey item remove failed', e);
mutationError = m.journey_mutation_error_reload(); mutationError = m.journey_mutation_error_reload();
} finally {
pendingRemoveIds = pendingRemoveIds.filter((id) => id !== itemId);
} }
} }
@@ -154,27 +197,27 @@ async function handleNotePatch(itemId: string, note: string | null) {
async function handleMoveUp(index: number) { async function handleMoveUp(index: number) {
if (index === 0) return; if (index === 0) return;
const total = items.length;
const ids = items.map((i) => i.id); const ids = items.map((i) => i.id);
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]]; [ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
liveAnnounce = m.journey_item_moved({
position: index + 1,
total: items.length,
newPosition: index
});
await handleReorder(ids); await handleReorder(ids);
// Announce only after the server confirmed (or rejected) the reorder —
// announcing beforehand would claim success for a move that rolled back.
liveAnnounce = mutationError
? mutationError
: m.journey_item_moved({ position: index + 1, total, newPosition: index });
scheduleAnnounceReset(); scheduleAnnounceReset();
} }
async function handleMoveDown(index: number) { async function handleMoveDown(index: number) {
if (index === items.length - 1) return; if (index === items.length - 1) return;
const total = items.length;
const ids = items.map((i) => i.id); const ids = items.map((i) => i.id);
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]]; [ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
liveAnnounce = m.journey_item_moved({
position: index + 1,
total: items.length,
newPosition: index + 2
});
await handleReorder(ids); await handleReorder(ids);
liveAnnounce = mutationError
? mutationError
: m.journey_item_moved({ position: index + 1, total, newPosition: index + 2 });
scheduleAnnounceReset(); scheduleAnnounceReset();
} }
@@ -196,7 +239,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
<!-- Editor column --> <!-- Editor column -->
<div class="flex flex-col gap-4"> <div bind:this={editorColEl} class="flex flex-col gap-4">
<!-- Title --> <!-- Title -->
<div> <div>
<input <input
@@ -205,6 +248,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
oninput={() => unsaved.markDirty()} oninput={() => unsaved.markDirty()}
onblur={() => (titleTouched = true)} onblur={() => (titleTouched = true)}
placeholder={m.geschichte_editor_title_placeholder()} placeholder={m.geschichte_editor_title_placeholder()}
aria-label={m.journey_title_aria_label()}
aria-invalid={showTitleError} aria-invalid={showTitleError}
aria-describedby={showTitleError ? 'journey-title-error' : undefined} aria-describedby={showTitleError ? 'journey-title-error' : undefined}
class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
@@ -222,6 +266,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
bind:value={body} bind:value={body}
oninput={() => unsaved.markDirty()} oninput={() => unsaved.markDirty()}
placeholder={m.journey_intro_placeholder()} placeholder={m.journey_intro_placeholder()}
aria-label={m.journey_intro_aria_label()}
rows={3} rows={3}
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-serif text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-serif text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea> ></textarea>
@@ -231,7 +276,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
<!-- Item list --> <!-- Item list -->
{#if showPublishedEmptyWarning} {#if showPublishedEmptyWarning}
<p <p
class="rounded border border-amber-300 bg-amber-50 px-3 py-2 font-sans text-sm text-amber-800" class="rounded border border-[var(--color-warning-border)] bg-[var(--color-warning-bg)] px-3 py-2 font-sans text-sm text-[var(--color-warning-text)]"
> >
{m.journey_published_empty_warning()} {m.journey_published_empty_warning()}
</p> </p>
@@ -275,6 +320,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
item={item} item={item}
index={i} index={i}
total={items.length} total={items.length}
pendingRemove={pendingRemoveIds.includes(item.id)}
onMoveUp={() => handleMoveUp(i)} onMoveUp={() => handleMoveUp(i)}
onMoveDown={() => handleMoveDown(i)} onMoveDown={() => handleMoveDown(i)}
onRemove={() => handleRemove(item.id)} onRemove={() => handleRemove(item.id)}
@@ -302,6 +348,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
<p class="font-sans text-xs text-ink-3"> <p class="font-sans text-xs text-ink-3">
{isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()} {isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()}
</p> </p>
<div class="flex flex-col items-start gap-1 sm:items-end">
<div class="flex gap-2"> <div class="flex gap-2">
{#if isDraft} {#if isDraft}
<button <button
@@ -326,7 +373,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
type="button" type="button"
onclick={() => save('DRAFT')} onclick={() => save('DRAFT')}
disabled={submitting} disabled={submitting}
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-amber-700 hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50" class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-[var(--color-warning-text)] hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
> >
{m.geschichte_editor_unpublish()} {m.geschichte_editor_unpublish()}
</button> </button>
@@ -340,4 +387,8 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
</button> </button>
{/if} {/if}
</div> </div>
{#if isDraft && !canPublish}
<p class="font-sans text-xs text-ink-3">{m.journey_publish_disabled_hint()}</p>
{/if}
</div>
</div> </div>

View File

@@ -1,12 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyEditor from './JourneyEditor.svelte'; import JourneyEditor from './JourneyEditor.svelte';
const docSummary = (id: string, title: string) => ({ const docSummary = (id: string, title: string) => ({
id, id,
title, title,
datePrecision: 'DAY' as const datePrecision: 'DAY' as const,
receiverCount: 0
});
/** DocumentListItem fixture as returned by the picker search endpoint. */
const makeSearchResultItem = (id: string, title: string) => ({
id,
title,
documentDate: '1880-01-01',
metaDatePrecision: 'DAY',
originalFilename: 'brief.pdf',
receivers: [],
tags: [],
completionPercentage: 0,
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}); });
const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({ const makeGeschichte = (overrides: Record<string, unknown> = {}) => ({
@@ -51,14 +79,19 @@ describe('JourneyEditor — empty state', () => {
await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument(); await expect.element(page.getByPlaceholder(/Einleitung/)).toBeInTheDocument();
}); });
it('publish button disabled when no items', async () => { it('labels the title input and intro textarea for screen readers', async () => {
render(JourneyEditor, defaultProps()); render(JourneyEditor, defaultProps());
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); await expect
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
.toBeInTheDocument();
await expect
.element(page.getByRole('textbox', { name: m.journey_intro_aria_label() }))
.toBeInTheDocument();
}); });
it('shows empty state message when items list is empty', async () => { it('shows empty state message when items list is empty', async () => {
render(JourneyEditor, defaultProps()); render(JourneyEditor, defaultProps());
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).toBeInTheDocument(); await expect.element(page.getByText(m.journey_empty_state())).toBeInTheDocument();
}); });
}); });
@@ -77,7 +110,17 @@ describe('JourneyEditor — items in position order', () => {
}); });
}); });
describe('JourneyEditor — publish disabled when title empty', () => { describe('JourneyEditor — publish surface', () => {
it('publish button disabled when no items', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
});
it('shows a visible hint while publishing is disabled', async () => {
render(JourneyEditor, defaultProps());
await expect.element(page.getByText(m.journey_publish_disabled_hint())).toBeInTheDocument();
});
it('publish stays disabled until title is non-empty', async () => { it('publish stays disabled until title is non-empty', async () => {
render( render(
JourneyEditor, JourneyEditor,
@@ -96,107 +139,6 @@ describe('JourneyEditor — publish disabled when title empty', () => {
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled(); await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
}); });
});
describe('JourneyEditor — add document', () => {
it('calls POST with documentId when document selected from picker', async () => {
const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') };
mockCsrfFetch(() => newItem);
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
// picker search results
ok: true,
json: vi.fn().mockResolvedValue({
items: [
{
id: 'd1',
title: 'Brief von Karl',
documentDate: '1880-01-01',
metaDatePrecision: 'DAY',
originalFilename: 'brief.pdf',
receivers: [],
tags: [],
completionPercentage: 0,
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}
]
})
})
.mockResolvedValueOnce({
// POST /items
ok: true,
json: vi.fn().mockResolvedValue(newItem)
})
);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText('Brief hinzufügen'));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
await new Promise((r) => setTimeout(r, 350)); // wait debounce
await userEvent.click(page.getByText(/Brief von Karl/));
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({ method: 'POST' })
);
});
});
describe('JourneyEditor — add interlude', () => {
it('calls POST with note on interlude confirm', async () => {
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Reise nach Wien');
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true }));
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ note: 'Reise nach Wien' })
})
);
});
});
describe('JourneyEditor — remove with rollback', () => {
it('restores item on failed DELETE (non-ok response)', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
// Click remove (no note → direct remove)
await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' }));
await new Promise((r) => setTimeout(r, 50));
// Item should be restored after rollback
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
});
it('item-add enables publish button (isDirty stays false, canPublish becomes true)', async () => { it('item-add enables publish button (isDirty stays false, canPublish becomes true)', async () => {
const newItem = { id: 'i1', position: 0, note: 'Test' }; const newItem = { id: 'i1', position: 0, note: 'Test' };
@@ -208,14 +150,248 @@ describe('JourneyEditor — remove with rollback', () => {
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled(); await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).toBeDisabled();
// Add interlude // Add interlude
await userEvent.click(page.getByText('Zwischentext hinzufügen')); await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByPlaceholder('Zwischentext eingeben…'), 'Test'); await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Test');
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen', exact: true })); await userEvent.click(
await new Promise((r) => setTimeout(r, 50)); page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
// After item add, publish becomes enabled — item was added and state is correct // After item add, publish becomes enabled — item was added and state is correct
await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled(); await expect.element(page.getByRole('button', { name: /Veröffentlichen/ })).not.toBeDisabled();
}); });
it('clicking Veröffentlichen calls onSubmit with status PUBLISHED and the trimmed title', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
JourneyEditor,
defaultProps({
onSubmit,
geschichte: makeGeschichte({
title: ' Meine Reise ',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await userEvent.click(page.getByRole('button', { name: /Veröffentlichen/ }));
await vi.waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ status: 'PUBLISHED', title: 'Meine Reise' })
);
});
});
it('unpublish button calls onSubmit with status DRAFT in PUBLISHED state', async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(
JourneyEditor,
defaultProps({
onSubmit,
geschichte: makeGeschichte({
status: 'PUBLISHED',
items: [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }]
})
})
);
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_unpublish() }));
await vi.waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ status: 'DRAFT' }));
});
});
it('renders the published-empty warning banner when PUBLISHED with 0 items', async () => {
render(
JourneyEditor,
defaultProps({ geschichte: makeGeschichte({ status: 'PUBLISHED', items: [] }) })
);
await expect.element(page.getByText(m.journey_published_empty_warning())).toBeInTheDocument();
});
});
describe('JourneyEditor — add document', () => {
it('calls POST with documentId when document selected from picker', async () => {
const newItem = { id: 'i1', position: 0, document: docSummary('d1', 'Brief von Karl') };
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValueOnce({
// picker search results
ok: true,
json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
})
.mockResolvedValueOnce({
// POST /items
ok: true,
json: vi.fn().mockResolvedValue(newItem)
})
);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_document()));
await userEvent.fill(page.getByRole('combobox'), 'Karl');
// dropdown option appears after the typeahead debounce
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
await userEvent.click(page.getByText(/Brief von Karl ·/));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({ method: 'POST' })
);
});
});
});
describe('JourneyEditor — add interlude', () => {
it('calls POST with note on interlude confirm', async () => {
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ note: 'Reise nach Wien' })
})
);
});
});
it('moves keyboard focus into the new row after the interlude is added', async () => {
const newItem = { id: 'i1', position: 0, note: 'Reise nach Wien' };
mockCsrfFetch(() => newItem);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await vi.waitFor(() => {
expect(document.activeElement?.closest('[data-block-id="i1"]')).toBeTruthy();
});
});
});
describe('JourneyEditor — mutation error code routing', () => {
it('shows the specific i18n message when POST /items fails with a backend error code', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({ code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' })
})
);
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(
page.getByPlaceholder(m.journey_interlude_placeholder()),
'Reise nach Wien'
);
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
await expect
.element(page.getByText(m.error_journey_document_already_added()))
.toBeInTheDocument();
await expect.element(page.getByText(m.journey_mutation_error_reload())).not.toBeInTheDocument();
});
});
describe('JourneyEditor — remove with pending state', () => {
it('keeps the row in the DOM with pending treatment while the DELETE is in flight', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
let resolveFetch!: (value: unknown) => void;
vi.stubGlobal(
'fetch',
vi.fn().mockImplementation(() => new Promise((resolve) => (resolveFetch = resolve)))
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
// Row still present, marked as pending (text appears in the row AND the live region,
// so scope the query to the row instead of using a page-wide locator)
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
await vi.waitFor(() => {
const row = document.querySelector('[data-block-id="i1"]');
expect(row).toBeTruthy();
expect(row!.textContent).toContain(m.journey_item_pending_remove());
expect(row!.className).toContain('opacity-60');
});
resolveFetch({ ok: true });
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
});
it('keeps the row and shows an error alert on failed DELETE (non-ok response)', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
// Click remove (no note → direct remove)
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
});
it('removes the row on successful DELETE', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
});
it('focuses a sensible target after a successful remove (not body)', async () => {
const items = [{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
await vi.waitFor(() => {
expect(document.activeElement).not.toBe(document.body);
expect(document.activeElement?.hasAttribute('data-add-document')).toBe(true);
});
});
}); });
describe('JourneyEditor — reorder via move buttons', () => { describe('JourneyEditor — reorder via move buttons', () => {
@@ -238,8 +414,8 @@ describe('JourneyEditor — reorder via move buttons', () => {
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await new Promise((r) => setTimeout(r, 50)); // handleMoveUp → handleReorder → csrfFetch: two await levels
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'), expect.stringContaining('/items/reorder'),
expect.objectContaining({ expect.objectContaining({
@@ -248,6 +424,7 @@ describe('JourneyEditor — reorder via move buttons', () => {
}) })
); );
}); });
});
it('move-down calls PUT reorder with swapped IDs', async () => { it('move-down calls PUT reorder with swapped IDs', async () => {
const items = [ const items = [
@@ -268,8 +445,8 @@ describe('JourneyEditor — reorder via move buttons', () => {
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ })); await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
await new Promise((r) => setTimeout(r, 50)); // handleMoveDown → handleReorder → csrfFetch: two await levels
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/reorder'), expect.stringContaining('/items/reorder'),
expect.objectContaining({ expect.objectContaining({
@@ -280,8 +457,49 @@ describe('JourneyEditor — reorder via move buttons', () => {
}); });
}); });
it('restores the original DOM order and shows an alert on failed reorder (non-ok)', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('restores the original DOM order and shows an alert when the reorder request rejects', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await expect.element(page.getByRole('alert')).toBeInTheDocument();
const briefA = page.getByText('Brief A').element();
const briefB = page.getByText('Brief B').element();
expect(briefA.compareDocumentPosition(briefB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});
});
describe('JourneyEditor — live announce region', () => { describe('JourneyEditor — live announce region', () => {
it('clears the live announce region 500ms after a move operation', async () => { it('announces the move only after the reorder resolved, then clears', async () => {
const items = [ const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') }, { id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') } { id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
@@ -300,13 +518,38 @@ describe('JourneyEditor — live announce region', () => {
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ })); await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
await new Promise((r) => setTimeout(r, 50)); // wait for csrfFetch
const liveRegion = document.querySelector('[aria-live="polite"]'); const liveRegion = document.querySelector('[aria-live="polite"]');
await vi.waitFor(() => {
expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0); expect((liveRegion?.textContent ?? '').trim().length).toBeGreaterThan(0);
});
await new Promise((r) => setTimeout(r, 650)); // 500ms clear timeout + buffer await vi.waitFor(
() => {
expect((liveRegion?.textContent ?? '').trim()).toBe(''); expect((liveRegion?.textContent ?? '').trim()).toBe('');
},
{ timeout: 2000 }
);
});
it('announces the error text instead of a success message when the move fails', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: vi.fn().mockResolvedValue({}) })
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief B.*nach oben verschieben/ }));
const liveRegion = document.querySelector('[aria-live="polite"]');
await vi.waitFor(() => {
expect((liveRegion?.textContent ?? '').trim()).toBe(m.journey_mutation_error_reload());
});
}); });
}); });
@@ -330,8 +573,8 @@ describe('JourneyEditor — note patch body', () => {
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ }); const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await userEvent.clear(textarea); await userEvent.clear(textarea);
await textarea.element().dispatchEvent(new FocusEvent('blur')); await textarea.element().dispatchEvent(new FocusEvent('blur'));
await new Promise((r) => setTimeout(r, 50));
await vi.waitFor(() => {
expect(globalThis.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/items/i1'), expect.stringContaining('/items/i1'),
expect.objectContaining({ expect.objectContaining({
@@ -341,6 +584,7 @@ describe('JourneyEditor — note patch body', () => {
); );
}); });
}); });
});
describe('JourneyEditor — duplicate document aria-disabled', () => { describe('JourneyEditor — duplicate document aria-disabled', () => {
it('already-added document appears as aria-disabled in picker', async () => { it('already-added document appears as aria-disabled in picker', async () => {
@@ -349,44 +593,17 @@ describe('JourneyEditor — duplicate document aria-disabled', () => {
'fetch', 'fetch',
vi.fn().mockResolvedValue({ vi.fn().mockResolvedValue({
ok: true, ok: true,
json: vi.fn().mockResolvedValue({ json: vi.fn().mockResolvedValue({ items: [makeSearchResultItem('d1', 'Brief von Karl')] })
items: [
{
id: 'd1',
title: 'Brief von Karl',
documentDate: '1880-01-01',
metaDatePrecision: 'DAY',
originalFilename: 'brief.pdf',
receivers: [],
tags: [],
completionPercentage: 0,
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
status: 'UPLOADED',
metadataComplete: false,
scriptType: 'UNKNOWN',
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}
]
})
}) })
); );
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) })); render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByText('Brief hinzufügen')); await userEvent.click(page.getByText(m.journey_add_document()));
await userEvent.fill(page.getByRole('combobox'), 'Karl'); await userEvent.fill(page.getByRole('combobox'), 'Karl');
await new Promise((r) => setTimeout(r, 350));
// The dropdown item includes the date ("Brief von Karl · …"), the list item does not // The dropdown item includes the date ("Brief von Karl · …"), the list item does not
await expect.element(page.getByText(/Brief von Karl ·/)).toBeInTheDocument();
const option = page const option = page
.getByText(/Brief von Karl ·/) .getByText(/Brief von Karl ·/)
.element() .element()

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
@@ -8,18 +9,29 @@ interface Props {
item: JourneyItemView; item: JourneyItemView;
index: number; index: number;
total: number; total: number;
pendingRemove?: boolean;
onMoveUp: () => void; onMoveUp: () => void;
onMoveDown: () => void; onMoveDown: () => void;
onRemove: () => void; onRemove: () => void;
onNotePatch: (note: string | null) => Promise<void>; onNotePatch: (note: string | null) => Promise<void>;
} }
let { item, index, total, onMoveUp, onMoveDown, onRemove, onNotePatch }: Props = $props(); let {
item,
index,
total,
pendingRemove = false,
onMoveUp,
onMoveDown,
onRemove,
onNotePatch
}: Props = $props();
const isInterlude = $derived(!item.document); const isInterlude = $derived(!item.document);
const itemTitle = $derived(item.document?.title ?? m.journey_add_interlude()); const itemTitle = $derived(item.document?.title ?? m.journey_interlude_label());
const needsConfirmOnRemove = $derived(!!item.note); const needsConfirmOnRemove = $derived(!!item.note);
let rootEl: HTMLElement | null = $state(null);
let showNote = $state(!!item.note); let showNote = $state(!!item.note);
let noteDraft = $state(item.note ?? ''); let noteDraft = $state(item.note ?? '');
let noteSaving = $state(false); let noteSaving = $state(false);
@@ -29,7 +41,12 @@ let showRemoveConfirm = $state(false);
async function handleNoteBlur() { async function handleNoteBlur() {
if (noteSaving) return; if (noteSaving) return;
if (noteDraft === item.note) return; if (noteDraft === item.note) return;
if (isInterlude && noteDraft.trim().length === 0) return; if (isInterlude && noteDraft.trim().length === 0) {
// Interludes must keep a note — restore the draft so the UI doesn't show
// an emptied text that the server still holds.
noteDraft = item.note ?? '';
return;
}
noteSaving = true; noteSaving = true;
noteError = ''; noteError = '';
@@ -64,23 +81,37 @@ function handleRemoveClick() {
onRemove(); onRemove();
} }
} }
function handleRemoveConfirm() {
showRemoveConfirm = false;
onRemove();
}
async function handleRemoveCancel() {
showRemoveConfirm = false;
await tick();
rootEl?.querySelector<HTMLElement>('[data-remove-btn]')?.focus();
}
</script> </script>
<div <div
bind:this={rootEl}
data-block-id={item.id} data-block-id={item.id}
class={[ class={[
'flex min-w-0 flex-col rounded border transition-colors', 'flex min-w-0 flex-col rounded border transition-colors',
pendingRemove ? 'opacity-60' : '',
isInterlude isInterlude
? 'border-l-4 border-[var(--color-interlude-border)] bg-[var(--color-interlude-bg)]' ? 'border-l-4 border-[var(--color-interlude-border)] bg-[var(--color-interlude-bg)]'
: 'border-line bg-surface' : 'border-line bg-surface'
].join(' ')} ].join(' ')}
> >
<div class="flex min-w-0 items-start gap-1 px-2 py-2"> <div class="flex min-w-0 items-start gap-1 px-2 py-2">
<!-- Drag handle (desktop) --> <!-- Drag handle (desktop, pointer-only — keyboard users reorder via the move buttons) -->
<button <button
type="button" type="button"
data-drag-handle data-drag-handle
aria-label={m.journey_drag_aria_label({ title: itemTitle })} tabindex="-1"
aria-hidden="true"
class="hidden shrink-0 cursor-grab items-center justify-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex" class="hidden shrink-0 cursor-grab items-center justify-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
style="min-height: 44px; min-width: 44px;" style="min-height: 44px; min-width: 44px;"
> >
@@ -91,6 +122,7 @@ function handleRemoveClick() {
<div class="flex shrink-0 flex-col"> <div class="flex shrink-0 flex-col">
<button <button
type="button" type="button"
data-move-up
onclick={onMoveUp} onclick={onMoveUp}
disabled={index === 0} disabled={index === 0}
aria-label={m.journey_move_up({ title: itemTitle })} aria-label={m.journey_move_up({ title: itemTitle })}
@@ -120,7 +152,7 @@ function handleRemoveClick() {
class="font-sans text-xs font-bold tracking-widest uppercase" class="font-sans text-xs font-bold tracking-widest uppercase"
style="color: var(--color-interlude-label);" style="color: var(--color-interlude-label);"
> >
{m.journey_add_interlude()} {m.journey_interlude_label()}
</span> </span>
{:else} {:else}
<span class="font-sans text-xs text-ink-3">{index + 1}.</span> <span class="font-sans text-xs text-ink-3">{index + 1}.</span>
@@ -128,21 +160,25 @@ function handleRemoveClick() {
{/if} {/if}
</div> </div>
<!-- Remove button / confirm --> <!-- Remove button / confirm / pending -->
<div class="shrink-0"> <div class="shrink-0">
{#if showRemoveConfirm} {#if pendingRemove}
<span class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 italic">
{m.journey_item_pending_remove()}
</span>
{:else if showRemoveConfirm}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span> <span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span>
<button <button
type="button" type="button"
onclick={onRemove} onclick={handleRemoveConfirm}
class="inline-flex min-h-[44px] items-center rounded bg-danger px-3 font-sans text-xs font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="inline-flex min-h-[44px] items-center rounded bg-danger px-3 font-sans text-xs font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
{m.journey_remove_confirm_yes()} {m.journey_remove_confirm_yes()}
</button> </button>
<button <button
type="button" type="button"
onclick={() => (showRemoveConfirm = false)} onclick={handleRemoveCancel}
class="inline-flex min-h-[44px] items-center rounded border border-line px-3 font-sans text-xs font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="inline-flex min-h-[44px] items-center rounded border border-line px-3 font-sans text-xs font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
{m.journey_remove_confirm_cancel()} {m.journey_remove_confirm_cancel()}
@@ -151,9 +187,10 @@ function handleRemoveClick() {
{:else} {:else}
<button <button
type="button" type="button"
data-remove-btn
onclick={handleRemoveClick} onclick={handleRemoveClick}
aria-label={m.journey_remove_item_aria()} aria-label={m.journey_remove_item_aria()}
class="-m-1 rounded p-3 text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="-m-1 inline-flex min-h-[44px] min-w-[44px] items-center justify-center rounded p-3 text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -186,7 +223,7 @@ function handleRemoveClick() {
<button <button
type="button" type="button"
onclick={handleNoteRemove} onclick={handleNoteRemove}
class="font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
{m.journey_note_remove()} {m.journey_note_remove()}
</button> </button>
@@ -203,7 +240,7 @@ function handleRemoveClick() {
onclick={() => { onclick={() => {
showNote = true; showNote = true;
}} }}
class="font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" class="inline-flex min-h-[44px] items-center font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
> >
{m.journey_note_add()} {m.journey_note_add()}
</button> </button>

View File

@@ -1,12 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser'; import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import JourneyItemRow from './JourneyItemRow.svelte'; import JourneyItemRow from './JourneyItemRow.svelte';
const docItem = (overrides: Partial<{ note: string }> = {}) => ({ const docItem = (overrides: Partial<{ note: string }> = {}) => ({
id: 'item-1', id: 'item-1',
position: 0, position: 0,
document: { id: 'doc-1', title: 'Brief von Karl', datePrecision: 'DAY' as const }, document: {
id: 'doc-1',
title: 'Brief von Karl',
datePrecision: 'DAY' as const,
receiverCount: 0
},
...overrides ...overrides
}); });
@@ -28,11 +34,32 @@ const defaultProps = (overrides = {}) => ({
afterEach(() => cleanup()); afterEach(() => cleanup());
describe('JourneyItemRow — interlude label', () => {
it('shows "Zwischentext" (not the add-button label) on interlude rows', async () => {
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
await expect.element(page.getByText(m.journey_interlude_label())).toBeInTheDocument();
await expect.element(page.getByText(m.journey_add_interlude())).not.toBeInTheDocument();
});
it('uses "Zwischentext" in the move button aria-labels', async () => {
render(JourneyItemRow, { item: interludeItem(), ...defaultProps({ index: 1 }) });
await expect
.element(
page.getByRole('button', {
name: m.journey_move_up({ title: m.journey_interlude_label() })
})
)
.toBeInTheDocument();
});
});
describe('JourneyItemRow — note textarea', () => { describe('JourneyItemRow — note textarea', () => {
it('opens note textarea on "Notiz hinzufügen" click', async () => { it('opens note textarea on "Notiz hinzufügen" click', async () => {
render(JourneyItemRow, { item: docItem(), ...defaultProps() }); render(JourneyItemRow, { item: docItem(), ...defaultProps() });
await userEvent.click(page.getByText('Notiz hinzufügen')); await userEvent.click(page.getByText(m.journey_note_add()));
await expect await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ })) .element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }))
@@ -43,13 +70,20 @@ describe('JourneyItemRow — note textarea', () => {
const onNotePatch = vi.fn().mockResolvedValue(undefined); const onNotePatch = vi.fn().mockResolvedValue(undefined);
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) }); render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
await userEvent.click(page.getByText('Notiz hinzufügen')); await userEvent.click(page.getByText(m.journey_note_add()));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }); const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
await userEvent.fill(textarea, 'Eine neue Notiz'); await userEvent.fill(textarea, 'Eine neue Notiz');
await textarea.element().dispatchEvent(new FocusEvent('blur')); await textarea.element().dispatchEvent(new FocusEvent('blur'));
expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz'); expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz');
}); });
it('limits the note textarea to 2000 characters', async () => {
render(JourneyItemRow, { item: docItem({ note: 'Notiz' }), ...defaultProps() });
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await expect.element(textarea).toHaveAttribute('maxlength', '2000');
});
}); });
describe('JourneyItemRow — note error state', () => { describe('JourneyItemRow — note error state', () => {
@@ -57,7 +91,7 @@ describe('JourneyItemRow — note error state', () => {
const onNotePatch = vi.fn().mockRejectedValue(new Error('server error')); const onNotePatch = vi.fn().mockRejectedValue(new Error('server error'));
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) }); render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
await userEvent.click(page.getByText('Notiz hinzufügen')); await userEvent.click(page.getByText(m.journey_note_add()));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }); const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
await userEvent.fill(textarea, 'Eine Notiz'); await userEvent.fill(textarea, 'Eine Notiz');
await textarea.element().dispatchEvent(new FocusEvent('blur')); await textarea.element().dispatchEvent(new FocusEvent('blur'));
@@ -74,8 +108,7 @@ describe('JourneyItemRow — note remove error state', () => {
...defaultProps({ onNotePatch }) ...defaultProps({ onNotePatch })
}); });
await userEvent.click(page.getByText('Notiz entfernen')); await userEvent.click(page.getByText(m.journey_note_remove()));
await new Promise((r) => setTimeout(r, 50));
// textarea should be visible again (showNote restored) // textarea should be visible again (showNote restored)
await expect await expect
@@ -95,7 +128,7 @@ describe('JourneyItemRow — interlude rules', () => {
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ })) .element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
.toBeInTheDocument(); .toBeInTheDocument();
// But "Notiz entfernen" must be absent // But "Notiz entfernen" must be absent
await expect.element(page.getByText('Notiz entfernen')).not.toBeInTheDocument(); await expect.element(page.getByText(m.journey_note_remove())).not.toBeInTheDocument();
}); });
it('blocks saving empty text on interlude note blur', async () => { it('blocks saving empty text on interlude note blur', async () => {
@@ -111,6 +144,19 @@ describe('JourneyItemRow — interlude rules', () => {
expect(onNotePatch).not.toHaveBeenCalled(); expect(onNotePatch).not.toHaveBeenCalled();
}); });
it('restores the original note text after a blocked empty-clear blur', async () => {
render(JourneyItemRow, {
item: interludeItem('original text'),
...defaultProps()
});
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
await userEvent.clear(textarea);
await textarea.element().dispatchEvent(new FocusEvent('blur'));
await expect.element(textarea).toHaveValue('original text');
});
}); });
describe('JourneyItemRow — remove confirm', () => { describe('JourneyItemRow — remove confirm', () => {
@@ -121,10 +167,25 @@ describe('JourneyItemRow — remove confirm', () => {
}); });
// Click remove (x button) // Click remove (x button)
await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' })); await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await expect.element(page.getByText('Wirklich entfernen?')).toBeInTheDocument(); await expect.element(page.getByText(m.journey_remove_confirm())).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Bestätigen' })).toBeInTheDocument(); await expect
.element(page.getByRole('button', { name: m.journey_remove_confirm_yes() }))
.toBeInTheDocument();
});
it('clicking Bestätigen invokes onRemove (destructive path)', async () => {
const onRemove = vi.fn();
render(JourneyItemRow, {
item: docItem({ note: 'Wichtige Notiz' }),
...defaultProps({ onRemove })
});
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_yes() }));
expect(onRemove).toHaveBeenCalledTimes(1);
}); });
it('confirm cancel restores remove button without calling onRemove', async () => { it('confirm cancel restores remove button without calling onRemove', async () => {
@@ -134,13 +195,55 @@ describe('JourneyItemRow — remove confirm', () => {
...defaultProps({ onRemove }) ...defaultProps({ onRemove })
}); });
await userEvent.click(page.getByRole('button', { name: 'Eintrag entfernen' })); await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(page.getByRole('button', { name: 'Abbrechen' })); await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
expect(onRemove).not.toHaveBeenCalled(); expect(onRemove).not.toHaveBeenCalled();
// The remove button should be back // The remove button should be back
await expect await expect
.element(page.getByRole('button', { name: 'Eintrag entfernen' })) .element(page.getByRole('button', { name: m.journey_remove_item_aria() }))
.toBeInTheDocument(); .toBeInTheDocument();
}); });
it('confirm cancel returns keyboard focus to the row remove button', async () => {
render(JourneyItemRow, {
item: docItem({ note: 'Notiz' }),
...defaultProps()
});
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
await vi.waitFor(() => {
const removeBtn = page.getByRole('button', { name: m.journey_remove_item_aria() }).element();
expect(document.activeElement).toBe(removeBtn);
});
});
});
describe('JourneyItemRow — pending remove state', () => {
it('renders dimmed with the pending text and without a remove button', async () => {
render(JourneyItemRow, {
item: docItem(),
...defaultProps({ pendingRemove: true })
});
await expect.element(page.getByText(m.journey_item_pending_remove())).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: m.journey_remove_item_aria() }))
.not.toBeInTheDocument();
const root = document.querySelector('[data-block-id="item-1"]')!;
expect(root.className).toContain('opacity-60');
});
});
describe('JourneyItemRow — drag handle', () => {
it('is pointer-only: removed from tab order and hidden from the accessibility tree', async () => {
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
const handle = document.querySelector('[data-drag-handle]')!;
expect(handle.getAttribute('tabindex')).toBe('-1');
expect(handle.getAttribute('aria-hidden')).toBe('true');
});
}); });

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
vi.mock('$app/navigation', () => ({ vi.mock('$app/navigation', () => ({
beforeNavigate: () => {}, beforeNavigate: () => {},
@@ -21,13 +22,20 @@ const { default: GeschichtenEditPage } = await import('./+page.svelte');
afterEach(cleanup); afterEach(cleanup);
const baseData = (overrides: Record<string, unknown> = {}) => ({ const baseData = (overrides: Record<string, unknown> = {}) => ({
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: true,
geschichte: { geschichte: {
id: 'g1', id: 'g1',
title: 'Die Reise nach Berlin', title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>', body: '<p>Im Jahr 1923...</p>',
status: 'PUBLISHED' as 'DRAFT' | 'PUBLISHED', status: 'PUBLISHED' as 'DRAFT' | 'PUBLISHED',
type: 'STORY' as 'STORY' | 'JOURNEY',
persons: [], persons: [],
documents: [] items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}, },
...overrides ...overrides
}); });
@@ -60,4 +68,50 @@ describe('geschichten/[id]/edit page', () => {
const inputs = document.querySelectorAll('input, textarea, [contenteditable]'); const inputs = document.querySelectorAll('input, textarea, [contenteditable]');
expect(inputs.length).toBeGreaterThan(0); expect(inputs.length).toBeGreaterThan(0);
}); });
it('renders the JourneyEditor (add-bar, no TipTap toolbar) for JOURNEY-type geschichten', async () => {
render(GeschichtenEditPage, {
props: {
data: baseData({
geschichte: {
id: 'g1',
title: 'Die Reise nach Berlin',
body: '',
status: 'DRAFT' as const,
type: 'JOURNEY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}
})
}
});
await expect.element(page.getByText(m.journey_add_document())).toBeVisible();
expect(document.querySelector('[role="toolbar"]')).toBeNull();
});
it('renders the GeschichteEditor (TipTap toolbar, no add-bar) for STORY-type geschichten', async () => {
render(GeschichtenEditPage, {
props: {
data: baseData({
geschichte: {
id: 'g1',
title: 'Die Reise nach Berlin',
body: '<p>Im Jahr 1923...</p>',
status: 'DRAFT' as const,
type: 'STORY' as const,
persons: [],
items: [],
createdAt: '2024-01-01T00:00:00',
updatedAt: '2024-01-01T00:00:00'
}
})
}
});
await expect.element(page.getByRole('toolbar')).toBeVisible();
await expect.element(page.getByText(m.journey_add_document())).not.toBeInTheDocument();
});
}); });

View File

@@ -60,6 +60,7 @@ async function handleSubmit(e: SubmitEvent) {
bind:value={title} bind:value={title}
onblur={() => (titleTouched = true)} onblur={() => (titleTouched = true)}
placeholder={m.geschichte_editor_title_placeholder()} placeholder={m.geschichte_editor_title_placeholder()}
aria-label={m.journey_title_aria_label()}
aria-invalid={showTitleError} aria-invalid={showTitleError}
class="block w-full rounded border px-3 py-2 font-serif text-lg text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {showTitleError class="block w-full rounded border px-3 py-2 font-serif text-lg text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {showTitleError
? 'border-danger' ? 'border-danger'
@@ -74,7 +75,7 @@ async function handleSubmit(e: SubmitEvent) {
<button <button
type="submit" type="submit"
disabled={submitting} disabled={submitting}
class="rounded bg-brand-navy px-4 py-2 font-sans text-sm font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50" class="inline-flex h-11 items-center rounded bg-brand-navy px-4 font-sans text-sm font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-50"
> >
{m.journey_create_submit()} {m.journey_create_submit()}
</button> </button>

View File

@@ -0,0 +1,73 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import { m } from '$lib/paraglide/messages.js';
import { getErrorMessage } from '$lib/shared/errors';
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
const { default: JourneyCreate } = await import('./JourneyCreate.svelte');
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('JourneyCreate — failure path', () => {
it('renders the mapped error message when POST /api/geschichten fails with a code', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: vi.fn().mockResolvedValue({ code: 'VALIDATION_ERROR' })
})
);
render(JourneyCreate, {});
await userEvent.fill(
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
'Meine Lesereise'
);
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
const alert = page.getByRole('alert');
await expect.element(alert).toBeInTheDocument();
await expect.element(alert).toHaveTextContent(getErrorMessage('VALIDATION_ERROR'));
});
it('navigates to the edit page on success', async () => {
const { goto } = await import('$app/navigation');
vi.mocked(goto).mockClear();
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ id: 'g-new' })
})
);
render(JourneyCreate, {});
await userEvent.fill(
page.getByRole('textbox', { name: m.journey_title_aria_label() }),
'Meine Lesereise'
);
await userEvent.click(page.getByRole('button', { name: m.journey_create_submit() }));
await vi.waitFor(() => {
expect(goto).toHaveBeenCalledWith('/geschichten/g-new/edit');
});
});
it('has an accessible label on the title input', async () => {
vi.stubGlobal('fetch', vi.fn());
render(JourneyCreate, {});
await expect
.element(page.getByRole('textbox', { name: m.journey_title_aria_label() }))
.toBeInTheDocument();
});
});

View File

@@ -77,6 +77,11 @@
--color-warning: #b45309; --color-warning: #b45309;
--color-warning-fg: #ffffff; --color-warning-fg: #ffffff;
/* Warning surface — amber banner (bg/border/text), mode-aware */
--color-warning-bg: var(--c-warning-bg);
--color-warning-border: var(--c-warning-border);
--color-warning-text: var(--c-warning-text);
/* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */ /* Journey / Lesereise — orange semantic tokens (badge, interlude block, annotation) */
--color-journey-tint: var(--c-journey-bg); --color-journey-tint: var(--c-journey-bg);
--color-journey: var(--c-journey-text); --color-journey: var(--c-journey-text);
@@ -149,6 +154,11 @@
--c-interlude-border: #a1dcd8; --c-interlude-border: #a1dcd8;
--c-interlude-label: #4b5563; --c-interlude-label: #4b5563;
/* Warning surface — amber banner; text #92400E on #FFFBEB ≈ 7.7:1 — WCAG AAA ✓ */
--c-warning-bg: #fffbeb;
--c-warning-border: #fcd34d;
--c-warning-text: #92400e;
/* Tag color tokens — decorative dot colors on tag chips */ /* Tag color tokens — decorative dot colors on tag chips */
--c-tag-sage: #5a8a6a; --c-tag-sage: #5a8a6a;
--c-tag-sienna: #a0522d; --c-tag-sienna: #a0522d;
@@ -278,6 +288,12 @@
--c-interlude-bg: #151c22; --c-interlude-bg: #151c22;
--c-interlude-border: #00c7b1; --c-interlude-border: #00c7b1;
--c-interlude-label: #8b97a5; --c-interlude-label: #8b97a5;
/* Warning surface — muted amber on dark; text #FBD38D on #2A2113 ≈ 9.5:1 — WCAG AAA ✓
KEEP IN SYNC with :root[data-theme='dark'] */
--c-warning-bg: #2a2113;
--c-warning-border: #6d5417;
--c-warning-text: #fbd38d;
} }
} }
@@ -363,6 +379,11 @@
--c-interlude-bg: #151c22; --c-interlude-bg: #151c22;
--c-interlude-border: #00c7b1; --c-interlude-border: #00c7b1;
--c-interlude-label: #8b97a5; --c-interlude-label: #8b97a5;
/* Warning surface — KEEP IN SYNC with the @media block above */
--c-warning-bg: #2a2113;
--c-warning-border: #6d5417;
--c-warning-text: #fbd38d;
} }
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */ /* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */