Files
familienarchiv/frontend/src/lib/geschichte/JourneyItemRow.svelte
Marcel 8995b6e922 fix(journey-editor): note UX, error codes, list semantics, a11y labels
- handleNotePatch routes failures through failureMessage() so a backend
  JOURNEY_NOTE_TOO_LONG renders its specific message in the row
- handleNoteBlur: '' vs undefined no-op guard (no spurious PATCH
  {note:null}), empty blur collapses the textarea, clearing an existing
  note collapses after the PATCH lands (spec LE-3)
- 'Notiz hinzufügen' toggle gets aria-expanded and moves focus into the
  revealed textarea
- journey_remove_item_aria interpolates the item title (de/en/es); dead
  journey_drag_aria_label key deleted from all locales
- editor item list is an <ol> (screen readers announce the ordering)
- editor title input gets maxlength=255 + border-danger error cue; intro
  textarea gets maxlength=4000
- Briefmeta line (date · von X an Y) under document titles in the editor
  row via the shared formatDocumentMetaLine (spec LE-2)
- new specs: successful save clears the unsaved warning; item add does
  not arm the guard; server-confirmed order after successful reorder;
  blur-without-typing no-op; focus hand-off into the note textarea

Review round 3: Sara (1-3), Felix, Elicit (LE-2/LE-3), Leonie.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:28:38 +02:00

269 lines
8.9 KiB
Svelte

<script lang="ts">
import { tick } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { formatDocumentMetaLine } from './utils';
type JourneyItemView = components['schemas']['JourneyItemView'];
interface Props {
item: JourneyItemView;
index: number;
total: number;
pendingRemove?: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
onNotePatch: (note: string | null) => Promise<void>;
}
let {
item,
index,
total,
pendingRemove = false,
onMoveUp,
onMoveDown,
onRemove,
onNotePatch
}: Props = $props();
const isInterlude = $derived(!item.document);
const itemTitle = $derived(item.document?.title ?? m.journey_interlude_label());
// Spec LE-2 "Briefmeta": date · von X an Y disambiguates near-identical titles.
const metaLine = $derived(item.document ? formatDocumentMetaLine(item.document) : '');
const needsConfirmOnRemove = $derived(!!item.note);
let rootEl: HTMLElement | null = $state(null);
let showNote = $state(!!item.note);
let noteDraft = $state(item.note ?? '');
let noteSaving = $state(false);
let noteError = $state('');
let showRemoveConfirm = $state(false);
async function handleNoteBlur() {
if (noteSaving) return;
const normalizedDraft = noteDraft.trim().length === 0 ? null : noteDraft;
// '' and undefined both mean "no note" — never PATCH a no-op.
if (normalizedDraft === (item.note ?? null)) {
// Opened "Notiz hinzufügen" and blurred without typing → collapse again.
if (!isInterlude && normalizedDraft === null) showNote = false;
return;
}
if (isInterlude && normalizedDraft === null) {
// 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;
noteError = '';
try {
await onNotePatch(normalizedDraft);
// Clearing an existing note collapses the textarea after the PATCH lands.
if (normalizedDraft === null) showNote = false;
} catch (e) {
noteError = e instanceof Error && e.message ? e.message : m.journey_note_error();
} finally {
noteSaving = false;
}
}
async function handleNoteRemove() {
const prevDraft = noteDraft;
const prevShowNote = showNote;
noteDraft = '';
showNote = false;
noteError = '';
try {
await onNotePatch(null);
} catch (e) {
noteDraft = prevDraft;
showNote = prevShowNote;
noteError = e instanceof Error && e.message ? e.message : m.journey_note_error();
}
}
async function handleNoteOpen() {
showNote = true;
// Spec LE-3: focus moves into the revealed textarea.
await tick();
rootEl?.querySelector<HTMLTextAreaElement>('textarea')?.focus();
}
async function handleRemoveClick() {
if (needsConfirmOnRemove) {
showRemoveConfirm = true;
await tick();
rootEl?.querySelector<HTMLElement>('[data-remove-confirm-cancel]')?.focus();
} else {
onRemove();
}
}
function handleRemoveConfirm() {
showRemoveConfirm = false;
onRemove();
}
async function handleRemoveCancel() {
showRemoveConfirm = false;
await tick();
rootEl?.querySelector<HTMLElement>('[data-remove-btn]')?.focus();
}
</script>
<div
bind:this={rootEl}
data-block-id={item.id}
class={[
'flex min-w-0 flex-col rounded border transition-colors',
pendingRemove ? 'opacity-60' : '',
isInterlude
? 'border-l-4 border-line border-l-interlude-border bg-interlude-bg'
: 'border-line bg-surface'
].join(' ')}
>
<div class="flex min-w-0 items-center gap-1 px-2 py-1">
<!-- Drag handle (desktop, pointer-only — keyboard users reorder via the move buttons) -->
<button
type="button"
data-drag-handle
tabindex="-1"
aria-hidden="true"
class="hidden shrink-0 cursor-grab items-center justify-center self-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
style="min-height: 44px; min-width: 44px;"
>
</button>
<!-- Move up/down (mobile + always visible) -->
<div class="flex shrink-0 flex-col self-start">
<button
type="button"
data-move-up
onclick={onMoveUp}
disabled={index === 0}
aria-label={m.journey_move_up({ title: itemTitle })}
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
onclick={onMoveDown}
disabled={index === total - 1}
aria-label={m.journey_move_down({ title: itemTitle })}
class="flex min-h-[44px] min-w-[44px] items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<!-- Content (title + note inline) -->
<div class="min-w-0 flex-1 py-1 break-words">
{#if isInterlude}
<span class="font-sans text-xs font-bold tracking-widest text-interlude-label uppercase">
{m.journey_interlude_label()}
</span>
{:else}
<span class="font-sans text-xs text-ink-3">{index + 1}.</span>
<span class="ml-1 font-serif text-sm text-ink">{item.document!.title}</span>
{#if metaLine}
<p class="mt-0.5 font-sans text-xs text-ink-3">{metaLine}</p>
{/if}
{/if}
{#if showNote}
<div class="mt-2">
<textarea
aria-label={m.journey_note_aria_label({ title: itemTitle })}
bind:value={noteDraft}
onblur={handleNoteBlur}
maxlength={2000}
rows={2}
class="block w-full resize-y rounded border border-line bg-transparent px-2 py-1.5 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
></textarea>
<div class="mt-1 flex items-center justify-between gap-2">
<p class="font-sans text-xs text-ink-3">{m.journey_note_save_hint()}</p>
{#if !isInterlude}
<button
type="button"
onclick={handleNoteRemove}
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()}
</button>
{/if}
</div>
{#if noteError}
<p class="mt-1 font-sans text-xs text-danger" role="alert">{noteError}</p>
{/if}
</div>
{:else if !isInterlude}
<button
type="button"
onclick={handleNoteOpen}
aria-expanded={showNote}
class="mt-0.5 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()}
</button>
{/if}
</div>
<!-- Remove button / confirm / pending -->
<div class="shrink-0 self-start">
{#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 role="group" aria-label={m.journey_remove_confirm()} class="flex items-center gap-2">
<span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span>
<button
type="button"
onclick={handleRemoveConfirm}
onkeydown={(e) => e.key === 'Escape' && handleRemoveCancel()}
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()}
</button>
<button
type="button"
data-remove-confirm-cancel
onclick={handleRemoveCancel}
onkeydown={(e) => e.key === 'Escape' && 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"
>
{m.journey_remove_confirm_cancel()}
</button>
</div>
{:else}
<button
type="button"
data-remove-btn
onclick={handleRemoveClick}
aria-label={m.journey_remove_item_aria({ title: itemTitle })}
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
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
</div>