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:
Marcel
2026-06-11 08:28:38 +02:00
parent b9bed19610
commit 8995b6e922
7 changed files with 237 additions and 79 deletions

View File

@@ -66,6 +66,35 @@ describe('JourneyItemRow — note textarea', () => {
.toBeInTheDocument();
});
it('blur without typing does not call onNotePatch and collapses the textarea', async () => {
// '' (untouched draft) and undefined (no note) both mean "no note" — a
// spurious PATCH {note: null} must not fire, and the empty textarea closes.
const onNotePatch = vi.fn().mockResolvedValue(undefined);
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
await userEvent.click(page.getByText(m.journey_note_add()));
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
await textarea.element().dispatchEvent(new FocusEvent('blur'));
expect(onNotePatch).not.toHaveBeenCalled();
await expect.element(page.getByText(m.journey_note_add())).toBeInTheDocument();
});
it('moves focus into the textarea when "Notiz hinzufügen" opens it', async () => {
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
const toggle = page.getByText(m.journey_note_add());
expect(toggle.element().getAttribute('aria-expanded')).toBe('false');
await userEvent.click(toggle);
await vi.waitFor(() => {
const textarea = page
.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ })
.element();
expect(document.activeElement).toBe(textarea);
});
});
it('calls onNotePatch on textarea blur with non-empty value', async () => {
const onNotePatch = vi.fn().mockResolvedValue(undefined);
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
@@ -167,7 +196,9 @@ describe('JourneyItemRow — remove confirm', () => {
});
// Click remove (x button)
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await expect.element(page.getByText(m.journey_remove_confirm())).toBeInTheDocument();
await expect
@@ -182,7 +213,9 @@ describe('JourneyItemRow — remove confirm', () => {
...defaultProps({ onRemove })
});
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_yes() }));
expect(onRemove).toHaveBeenCalledTimes(1);
@@ -195,13 +228,17 @@ describe('JourneyItemRow — remove confirm', () => {
...defaultProps({ onRemove })
});
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await userEvent.click(page.getByRole('button', { name: m.journey_remove_confirm_cancel() }));
expect(onRemove).not.toHaveBeenCalled();
// The remove button should be back
await expect
.element(page.getByRole('button', { name: m.journey_remove_item_aria() }))
.element(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
)
.toBeInTheDocument();
});
@@ -211,11 +248,15 @@ describe('JourneyItemRow — remove confirm', () => {
...defaultProps()
});
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
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();
const removeBtn = page
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
.element();
expect(document.activeElement).toBe(removeBtn);
});
});
@@ -228,7 +269,9 @@ describe('JourneyItemRow — remove confirm a11y', () => {
...defaultProps()
});
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
const group = document.querySelector('[role="group"]');
expect(group).toBeTruthy();
@@ -241,7 +284,9 @@ describe('JourneyItemRow — remove confirm a11y', () => {
...defaultProps()
});
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await vi.waitFor(() => {
const cancelBtn = page
@@ -257,7 +302,9 @@ describe('JourneyItemRow — remove confirm a11y', () => {
...defaultProps()
});
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
);
await vi.waitFor(() => {
const cancelBtn = page
.getByRole('button', { name: m.journey_remove_confirm_cancel() })
@@ -268,7 +315,9 @@ describe('JourneyItemRow — remove confirm a11y', () => {
await userEvent.keyboard('{Escape}');
await vi.waitFor(() => {
const removeBtn = page.getByRole('button', { name: m.journey_remove_item_aria() }).element();
const removeBtn = page
.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
.element();
expect(document.activeElement).toBe(removeBtn);
});
await expect.element(page.getByText(m.journey_remove_confirm())).not.toBeInTheDocument();
@@ -284,7 +333,9 @@ describe('JourneyItemRow — pending remove state', () => {
await expect.element(page.getByText(m.journey_item_pending_remove())).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: m.journey_remove_item_aria() }))
.element(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief von Karl' }) })
)
.not.toBeInTheDocument();
const root = document.querySelector('[data-block-id="item-1"]')!;