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:
Marcel
2026-06-08 22:58:40 +02:00
parent 0b9e8c2abb
commit 565eddd743
6 changed files with 268 additions and 69 deletions

View File

@@ -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>

View File

@@ -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 };
};

View File

@@ -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>

View 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} />

View 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>

View File

@@ -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();
});
});