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:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
@@ -8,18 +9,29 @@ 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, onMoveUp, onMoveDown, onRemove, onNotePatch }: Props = $props();
|
||||
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_add_interlude());
|
||||
const itemTitle = $derived(item.document?.title ?? m.journey_interlude_label());
|
||||
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);
|
||||
@@ -29,7 +41,12 @@ let showRemoveConfirm = $state(false);
|
||||
async function handleNoteBlur() {
|
||||
if (noteSaving) 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;
|
||||
noteError = '';
|
||||
@@ -64,23 +81,37 @@ function handleRemoveClick() {
|
||||
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-[var(--color-interlude-border)] bg-[var(--color-interlude-bg)]'
|
||||
: 'border-line bg-surface'
|
||||
].join(' ')}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
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"
|
||||
style="min-height: 44px; min-width: 44px;"
|
||||
>
|
||||
@@ -91,6 +122,7 @@ function handleRemoveClick() {
|
||||
<div class="flex shrink-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
data-move-up
|
||||
onclick={onMoveUp}
|
||||
disabled={index === 0}
|
||||
aria-label={m.journey_move_up({ title: itemTitle })}
|
||||
@@ -120,7 +152,7 @@ function handleRemoveClick() {
|
||||
class="font-sans text-xs font-bold tracking-widest uppercase"
|
||||
style="color: var(--color-interlude-label);"
|
||||
>
|
||||
{m.journey_add_interlude()}
|
||||
{m.journey_interlude_label()}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-sans text-xs text-ink-3">{index + 1}.</span>
|
||||
@@ -128,21 +160,25 @@ function handleRemoveClick() {
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Remove button / confirm -->
|
||||
<!-- Remove button / confirm / pending -->
|
||||
<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">
|
||||
<span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span>
|
||||
<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"
|
||||
>
|
||||
{m.journey_remove_confirm_yes()}
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
{m.journey_remove_confirm_cancel()}
|
||||
@@ -151,9 +187,10 @@ function handleRemoveClick() {
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
data-remove-btn
|
||||
onclick={handleRemoveClick}
|
||||
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
|
||||
class="h-4 w-4"
|
||||
@@ -186,7 +223,7 @@ function handleRemoveClick() {
|
||||
<button
|
||||
type="button"
|
||||
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()}
|
||||
</button>
|
||||
@@ -203,7 +240,7 @@ function handleRemoveClick() {
|
||||
onclick={() => {
|
||||
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()}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user