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>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
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'];
|
||||
|
||||
@@ -29,6 +30,8 @@ let {
|
||||
|
||||
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);
|
||||
@@ -40,8 +43,14 @@ let showRemoveConfirm = $state(false);
|
||||
|
||||
async function handleNoteBlur() {
|
||||
if (noteSaving) return;
|
||||
if (noteDraft === item.note) return;
|
||||
if (isInterlude && noteDraft.trim().length === 0) {
|
||||
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 ?? '';
|
||||
@@ -51,9 +60,11 @@ async function handleNoteBlur() {
|
||||
noteSaving = true;
|
||||
noteError = '';
|
||||
try {
|
||||
await onNotePatch(noteDraft.trim().length === 0 ? null : noteDraft);
|
||||
} catch {
|
||||
noteError = m.journey_note_error();
|
||||
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;
|
||||
}
|
||||
@@ -67,13 +78,20 @@ async function handleNoteRemove() {
|
||||
noteError = '';
|
||||
try {
|
||||
await onNotePatch(null);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
noteDraft = prevDraft;
|
||||
showNote = prevShowNote;
|
||||
noteError = m.journey_note_error();
|
||||
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;
|
||||
@@ -103,7 +121,7 @@ async function handleRemoveCancel() {
|
||||
'flex min-w-0 flex-col rounded border transition-colors',
|
||||
pendingRemove ? 'opacity-60' : '',
|
||||
isInterlude
|
||||
? 'border-l-4 border-[var(--color-interlude-border)] bg-[var(--color-interlude-bg)]'
|
||||
? 'border-l-4 border-line border-l-interlude-border bg-interlude-bg'
|
||||
: 'border-line bg-surface'
|
||||
].join(' ')}
|
||||
>
|
||||
@@ -150,15 +168,15 @@ async function handleRemoveCancel() {
|
||||
<!-- 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 uppercase"
|
||||
style="color: var(--color-interlude-label);"
|
||||
>
|
||||
<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}
|
||||
@@ -190,9 +208,8 @@ async function handleRemoveCancel() {
|
||||
{:else if !isInterlude}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showNote = true;
|
||||
}}
|
||||
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()}
|
||||
@@ -232,7 +249,7 @@ async function handleRemoveCancel() {
|
||||
type="button"
|
||||
data-remove-btn
|
||||
onclick={handleRemoveClick}
|
||||
aria-label={m.journey_remove_item_aria()}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user