feat(lesereisen): TypeSelector (roving tabindex, aria-disabled), StoryCreate, type-gated new page, list uses GeschichteListRow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||
import GeschichteListRow from '$lib/geschichte/GeschichteListRow.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -36,18 +35,6 @@ function addPerson(personId: string) {
|
||||
function removePerson(personId: string) {
|
||||
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
|
||||
}
|
||||
|
||||
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
|
||||
const a = g.author;
|
||||
if (!a) return '';
|
||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||
return full || a.email || '';
|
||||
}
|
||||
|
||||
function publishedAt(g: { publishedAt?: string }): string | null {
|
||||
if (!g.publishedAt) return null;
|
||||
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
@@ -131,16 +118,8 @@ function publishedAt(g: { publishedAt?: string }): string | null {
|
||||
<li
|
||||
class="rounded border border-line bg-surface p-5 shadow-sm transition-shadow hover:shadow-md"
|
||||
>
|
||||
<a href="/geschichten/{g.id}" class="block">
|
||||
<h2 class="mb-1 font-serif text-xl font-bold text-ink">{g.title}</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">
|
||||
{authorName(g)}
|
||||
{#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if}
|
||||
</p>
|
||||
{#if g.body}
|
||||
<p class="font-serif text-base text-ink-2">{plainExcerpt(g.body, 150)}</p>
|
||||
{/if}
|
||||
</a>
|
||||
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
|
||||
<GeschichteListRow geschichte={g} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -19,5 +19,12 @@ export const load: PageServerLoad = async ({ url, fetch, parent }) => {
|
||||
const initialPersons =
|
||||
personResult && personResult.response.ok && personResult.data ? [personResult.data] : [];
|
||||
|
||||
return { initialPersons };
|
||||
// Validate ?type against the known union — prevents unexpected strings from reaching the API.
|
||||
// Security note: strict equality rejects encoded variants (e.g. STORY%00JOURNEY) and
|
||||
// only the FIRST value is returned by searchParams.get() on repeated params.
|
||||
const rawType = url.searchParams.get('type');
|
||||
const selectedType: 'STORY' | 'JOURNEY' | null =
|
||||
rawType === 'STORY' || rawType === 'JOURNEY' ? rawType : null;
|
||||
|
||||
return { initialPersons, selectedType };
|
||||
};
|
||||
|
||||
@@ -1,42 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
|
||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import TypeSelector from './TypeSelector.svelte';
|
||||
import StoryCreate from './StoryCreate.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
|
||||
async function handleSubmit(payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
personIds: string[];
|
||||
}) {
|
||||
submitting = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
const res = await csrfFetch('/api/geschichten', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
errorMessage = getErrorMessage(code);
|
||||
return;
|
||||
}
|
||||
const created = await res.json();
|
||||
goto(`/geschichten/${created.id}`);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||
@@ -46,18 +16,16 @@ async function handleSubmit(payload: {
|
||||
|
||||
<h1 class="mb-6 font-serif text-3xl font-bold text-ink">{m.geschichten_new_button()}</h1>
|
||||
|
||||
{#if errorMessage}
|
||||
<div
|
||||
class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger"
|
||||
role="alert"
|
||||
>
|
||||
{errorMessage}
|
||||
{#if data.selectedType === 'STORY'}
|
||||
<StoryCreate initialPersons={data.initialPersons} />
|
||||
{:else if data.selectedType === 'JOURNEY'}
|
||||
<div data-testid="journey-placeholder">
|
||||
<p class="mb-4 font-sans text-base text-ink-2">{m.journey_placeholder_heading()}</p>
|
||||
<a href="/geschichten/new" class="font-sans text-sm text-ink-3 underline hover:text-ink">
|
||||
{m.journey_placeholder_back()}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<TypeSelector onweiter={(type) => goto(`/geschichten/new?type=${type}`)} />
|
||||
{/if}
|
||||
|
||||
<GeschichteEditor
|
||||
initialPersons={data.initialPersons}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
50
frontend/src/routes/geschichten/new/StoryCreate.svelte
Normal file
50
frontend/src/routes/geschichten/new/StoryCreate.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import GeschichteEditor from '$lib/geschichte/GeschichteEditor.svelte';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
interface Props {
|
||||
initialPersons: components['schemas']['Person'][];
|
||||
}
|
||||
|
||||
let { initialPersons }: Props = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let errorMessage: string | null = $state(null);
|
||||
|
||||
async function handleSubmit(payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
personIds: string[];
|
||||
}) {
|
||||
submitting = true;
|
||||
errorMessage = null;
|
||||
try {
|
||||
const res = await csrfFetch('/api/geschichten', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...payload, type: 'STORY' })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
errorMessage = getErrorMessage(code);
|
||||
return;
|
||||
}
|
||||
const created = await res.json();
|
||||
goto(`/geschichten/${created.id}`);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="mb-4 rounded border border-danger bg-danger/10 p-3 text-sm text-danger" role="alert">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<GeschichteEditor initialPersons={initialPersons} onSubmit={handleSubmit} submitting={submitting} />
|
||||
96
frontend/src/routes/geschichten/new/TypeSelector.svelte
Normal file
96
frontend/src/routes/geschichten/new/TypeSelector.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
|
||||
|
||||
type GeschichteType = 'STORY' | 'JOURNEY';
|
||||
|
||||
const TYPES: GeschichteType[] = ['STORY', 'JOURNEY'];
|
||||
|
||||
interface Props {
|
||||
onweiter: (type: GeschichteType) => void;
|
||||
}
|
||||
|
||||
let { onweiter }: Props = $props();
|
||||
|
||||
let selected = $state<GeschichteType | null>(null);
|
||||
let announcement = $state('');
|
||||
|
||||
// Roving-tabindex holder: falls back to the first card so keyboard nav can start
|
||||
// even when nothing is selected (all cards at tabindex=-1 would be a keyboard dead-spot).
|
||||
const rovingFocusType = $derived(selected ?? TYPES[0]);
|
||||
|
||||
function select(type: GeschichteType) {
|
||||
selected = type;
|
||||
announcement = '';
|
||||
}
|
||||
|
||||
function handleWeiter() {
|
||||
if (!selected) {
|
||||
announcement = m.journey_selector_aria_live_hint();
|
||||
return;
|
||||
}
|
||||
onweiter(selected);
|
||||
}
|
||||
|
||||
const titles: Record<GeschichteType, () => string> = {
|
||||
STORY: m.journey_selector_story_title,
|
||||
JOURNEY: m.journey_selector_journey_title
|
||||
};
|
||||
|
||||
const descs: Record<GeschichteType, () => string> = {
|
||||
STORY: m.journey_selector_story_desc,
|
||||
JOURNEY: m.journey_selector_journey_desc
|
||||
};
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p id="type-selector-label" class="mb-4 font-sans text-base font-medium text-ink">
|
||||
{m.journey_selector_question()}
|
||||
</p>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-labelledby="type-selector-label"
|
||||
class="grid grid-cols-1 gap-4 sm:grid-cols-2"
|
||||
use:radioGroupNav={(v) => {
|
||||
if (TYPES.includes(v as GeschichteType)) select(v as GeschichteType);
|
||||
}}
|
||||
>
|
||||
{#each TYPES as type (type)}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
value={type}
|
||||
aria-checked={selected === type}
|
||||
tabindex={type === rovingFocusType ? 0 : -1}
|
||||
onclick={() => select(type)}
|
||||
class="min-h-[64px] cursor-pointer rounded border px-4 py-3 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {selected === type
|
||||
? 'border-primary bg-primary text-primary-fg'
|
||||
: 'border-line bg-surface text-ink hover:border-primary/50'}"
|
||||
>
|
||||
<span class="block font-sans text-sm font-bold">{titles[type]()}</span>
|
||||
<span class="mt-1 block font-sans text-xs text-current opacity-70">{descs[type]()}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{announcement}</div>
|
||||
|
||||
{#if !selected}
|
||||
<p class="mt-3 font-sans text-xs text-ink-3" aria-hidden="true">
|
||||
{m.journey_selector_aria_live_hint()}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-disabled={selected == null ? 'true' : 'false'}
|
||||
tabindex="0"
|
||||
onclick={handleWeiter}
|
||||
class="mt-6 inline-flex h-11 items-center rounded px-6 font-sans text-sm font-medium transition-opacity focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {selected == null
|
||||
? 'cursor-not-allowed bg-primary text-primary-fg opacity-50'
|
||||
: 'bg-primary text-primary-fg hover:opacity-90'}"
|
||||
>
|
||||
{m.journey_selector_next_btn()}
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
|
||||
vi.mock('$app/navigation', () => ({
|
||||
goto: vi.fn()
|
||||
}));
|
||||
|
||||
const { default: TypeSelector } = await import('./TypeSelector.svelte');
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('TypeSelector', () => {
|
||||
it('renders both type cards', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
await expect.element(page.getByRole('radio', { name: /Geschichte/i })).toBeVisible();
|
||||
await expect.element(page.getByRole('radio', { name: /Lesereise/i })).toBeVisible();
|
||||
});
|
||||
|
||||
it('radiogroup is correctly labelled', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const group = document.querySelector('[role="radiogroup"]');
|
||||
const labelledBy = group?.getAttribute('aria-labelledby');
|
||||
const labelEl = labelledBy ? document.getElementById(labelledBy) : null;
|
||||
expect(labelEl?.textContent?.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('Weiter button has aria-disabled=true when nothing is selected', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const weiter = document.querySelector('button[type="button"]:not([role="radio"])');
|
||||
expect(weiter?.getAttribute('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('no card is aria-checked when nothing is selected', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const radios = Array.from(document.querySelectorAll('[role="radio"]'));
|
||||
const anyChecked = radios.some((r) => r.getAttribute('aria-checked') === 'true');
|
||||
expect(anyChecked).toBe(false);
|
||||
});
|
||||
|
||||
it('with no selection: first card has tabindex=0, second has tabindex=-1', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const radios = Array.from(document.querySelectorAll('[role="radio"]'));
|
||||
expect(radios[0]?.getAttribute('tabindex')).toBe('0');
|
||||
expect(radios[1]?.getAttribute('tabindex')).toBe('-1');
|
||||
});
|
||||
|
||||
it('clicking STORY card sets aria-checked=true and enables Weiter', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const storyCard = page.getByRole('radio', { name: /Geschichte/i });
|
||||
await userEvent.click(storyCard);
|
||||
|
||||
await expect.element(storyCard).toHaveAttribute('aria-checked', 'true');
|
||||
const weiter = document.querySelector('button[type="button"]:not([role="radio"])');
|
||||
expect(weiter?.getAttribute('aria-disabled')).toBe('false');
|
||||
});
|
||||
|
||||
it('clicking JOURNEY card sets aria-checked=true', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
const journeyCard = page.getByRole('radio', { name: /Lesereise/i });
|
||||
await userEvent.click(journeyCard);
|
||||
|
||||
await expect.element(journeyCard).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('clicking Weiter after selection calls onweiter with the selected type', async () => {
|
||||
const onweiter = vi.fn();
|
||||
render(TypeSelector, { props: { onweiter } });
|
||||
|
||||
await userEvent.click(page.getByRole('radio', { name: /Geschichte/i }));
|
||||
const weiter = page.getByRole('button', { name: /Weiter/i });
|
||||
await userEvent.click(weiter);
|
||||
|
||||
expect(onweiter).toHaveBeenCalledWith('STORY');
|
||||
});
|
||||
|
||||
it('clicking Weiter without selection does NOT call onweiter', async () => {
|
||||
const onweiter = vi.fn();
|
||||
render(TypeSelector, { props: { onweiter } });
|
||||
|
||||
const weiter = page.getByRole('button', { name: /Weiter/i });
|
||||
await userEvent.click(weiter);
|
||||
|
||||
expect(onweiter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('instructional text is visible when no type is selected', async () => {
|
||||
render(TypeSelector, { props: { onweiter: vi.fn() } });
|
||||
|
||||
await expect.element(page.getByText(/Bitte wähle einen Typ/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user