Focus moves to Cancel when confirm appears (no focus-drops-to-body), confirm area has role=group with aria-label, and Escape dismisses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
255 lines
7.9 KiB
Svelte
255 lines
7.9 KiB
Svelte
<script lang="ts">
|
|
import { tick } from 'svelte';
|
|
import type { components } from '$lib/generated/api';
|
|
import { m } from '$lib/paraglide/messages.js';
|
|
|
|
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());
|
|
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;
|
|
if (noteDraft === item.note) 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 = '';
|
|
try {
|
|
await onNotePatch(noteDraft.trim().length === 0 ? null : noteDraft);
|
|
} catch {
|
|
noteError = 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 {
|
|
noteDraft = prevDraft;
|
|
showNote = prevShowNote;
|
|
noteError = m.journey_note_error();
|
|
}
|
|
}
|
|
|
|
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-[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, 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 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">
|
|
<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 -->
|
|
<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);"
|
|
>
|
|
{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}
|
|
</div>
|
|
|
|
<!-- Remove button / confirm / pending -->
|
|
<div class="shrink-0">
|
|
{#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()}
|
|
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>
|
|
|
|
<!-- Note section -->
|
|
{#if showNote}
|
|
<div class="border-t border-line/50 px-3 pt-2 pb-3">
|
|
<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}
|
|
<div class="px-3 pb-2">
|
|
<button
|
|
type="button"
|
|
onclick={() => {
|
|
showNote = true;
|
|
}}
|
|
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>
|
|
</div>
|
|
{/if}
|
|
</div>
|