feat(journey-editor): build JourneyAddBar with document picker and interlude draft
Two add buttons: document picker (DocumentPickerDropdown) and interlude inline draft form. Interlude confirm is aria-disabled until text is non-empty. Closing one panel opens the other. Tests cover all three plan test cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
110
frontend/src/lib/geschichte/JourneyAddBar.svelte
Normal file
110
frontend/src/lib/geschichte/JourneyAddBar.svelte
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
|
||||||
|
|
||||||
|
type DocumentListItem = components['schemas']['DocumentListItem'];
|
||||||
|
type DocumentOption = Pick<
|
||||||
|
DocumentListItem,
|
||||||
|
'id' | 'title' | 'documentDate' | 'metaDatePrecision' | 'metaDateEnd'
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
alreadyAddedIds?: Set<string>;
|
||||||
|
onAddDocument: (doc: DocumentOption) => void;
|
||||||
|
onAddInterlude: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { alreadyAddedIds = new Set(), onAddDocument, onAddInterlude }: Props = $props();
|
||||||
|
|
||||||
|
let showPicker = $state(false);
|
||||||
|
let showInterludeForm = $state(false);
|
||||||
|
let interludeDraft = $state('');
|
||||||
|
|
||||||
|
const canConfirmInterlude = $derived(interludeDraft.trim().length > 0);
|
||||||
|
|
||||||
|
function handleDocumentSelect(doc: DocumentOption) {
|
||||||
|
showPicker = false;
|
||||||
|
onAddDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInterludeConfirm() {
|
||||||
|
if (!canConfirmInterlude) return;
|
||||||
|
const text = interludeDraft.trim();
|
||||||
|
interludeDraft = '';
|
||||||
|
showInterludeForm = false;
|
||||||
|
onAddInterlude(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInterludeCancel() {
|
||||||
|
interludeDraft = '';
|
||||||
|
showInterludeForm = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
showPicker = !showPicker;
|
||||||
|
showInterludeForm = false;
|
||||||
|
}}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
+ {m.journey_add_document()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
showInterludeForm = !showInterludeForm;
|
||||||
|
showPicker = false;
|
||||||
|
}}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
+ {m.journey_add_interlude()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showPicker}
|
||||||
|
<DocumentPickerDropdown
|
||||||
|
alreadyAddedIds={alreadyAddedIds}
|
||||||
|
onSelect={handleDocumentSelect}
|
||||||
|
placeholder={m.journey_add_document()}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showInterludeForm}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
bind:value={interludeDraft}
|
||||||
|
placeholder={m.journey_interlude_placeholder()}
|
||||||
|
rows={3}
|
||||||
|
maxlength={2000}
|
||||||
|
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 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="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleInterludeConfirm}
|
||||||
|
aria-disabled={!canConfirmInterlude}
|
||||||
|
class={[
|
||||||
|
'inline-flex h-11 items-center rounded px-4 font-sans text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring',
|
||||||
|
canConfirmInterlude
|
||||||
|
? 'bg-primary text-primary-fg hover:opacity-90'
|
||||||
|
: 'cursor-not-allowed bg-primary/40 text-primary-fg/60'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{m.journey_add_interlude_confirm()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleInterludeCancel}
|
||||||
|
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm 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>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
53
frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts
Normal file
53
frontend/src/lib/geschichte/JourneyAddBar.svelte.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import JourneyAddBar from './JourneyAddBar.svelte';
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe('JourneyAddBar — interlude flow', () => {
|
||||||
|
it('interlude confirm button is aria-disabled until text is non-empty', async () => {
|
||||||
|
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
|
||||||
|
|
||||||
|
const confirmBtn = page.getByRole('button', { name: 'Hinzufügen' });
|
||||||
|
await expect.element(confirmBtn).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('confirm becomes active after typing text', async () => {
|
||||||
|
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
|
||||||
|
await userEvent.fill(page.getByRole('textbox'), 'Eine schöne Reise');
|
||||||
|
|
||||||
|
const confirmBtn = page.getByRole('button', { name: 'Hinzufügen' });
|
||||||
|
await expect.element(confirmBtn).toHaveAttribute('aria-disabled', 'false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onAddInterlude with text on confirm', async () => {
|
||||||
|
const onAddInterlude = vi.fn();
|
||||||
|
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText('Zwischentext hinzufügen'));
|
||||||
|
await userEvent.fill(page.getByRole('textbox'), 'Reise nach Wien');
|
||||||
|
await userEvent.click(page.getByRole('button', { name: 'Hinzufügen' }));
|
||||||
|
|
||||||
|
expect(onAddInterlude).toHaveBeenCalledWith('Reise nach Wien');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('JourneyAddBar — document picker', () => {
|
||||||
|
it('reveals picker when "Brief hinzufügen" is clicked', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ items: [] }) })
|
||||||
|
);
|
||||||
|
render(JourneyAddBar, { onAddDocument: vi.fn(), onAddInterlude: vi.fn() });
|
||||||
|
|
||||||
|
await userEvent.click(page.getByText('Brief hinzufügen'));
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user