feat(ui): add CorrespondenzHero with discovery headline and large typeahead
New centred hero component for the Briefwechsel page: headline "Wessen Briefe möchten Sie lesen?", cross-link to document search, h-14 PersonTypeahead, and recent persons chips. Adds `large` prop to PersonTypeahead and `conv_hero_crosslink` message key. Refs: #179 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -141,6 +141,7 @@
|
||||
"conv_sort_oldest": "Älteste zuerst",
|
||||
"conv_empty_heading": "Wessen Briefe möchten Sie lesen?",
|
||||
"conv_empty_text": "Wähle eine Person aus dem Archiv um deren Briefe zu sehen — mit oder ohne Korrespondent.",
|
||||
"conv_hero_crosslink": "Suchen Sie ein bestimmtes Dokument? → Zur Dokumentensuche",
|
||||
"conv_no_results_heading": "Keine Dokumente gefunden.",
|
||||
"conv_no_results_text": "Versuchen Sie, den Zeitraum anzupassen.",
|
||||
"conv_swap_btn": "Personen tauschen",
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"conv_sort_oldest": "Oldest first",
|
||||
"conv_empty_heading": "Whose letters would you like to read?",
|
||||
"conv_empty_text": "Choose a person from the archive to see their letters — with or without a correspondent.",
|
||||
"conv_hero_crosslink": "Looking for a specific document? → Go to document search",
|
||||
"conv_no_results_heading": "No documents found.",
|
||||
"conv_no_results_text": "Try adjusting the time period.",
|
||||
"conv_swap_btn": "Swap persons",
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"conv_sort_oldest": "Más antiguo primero",
|
||||
"conv_empty_heading": "¿De quién desea leer las cartas?",
|
||||
"conv_empty_text": "Elige una persona del archivo para ver sus cartas — con o sin corresponsal.",
|
||||
"conv_hero_crosslink": "¿Busca un documento en particular? → Ir a la búsqueda",
|
||||
"conv_no_results_heading": "No se encontraron documentos.",
|
||||
"conv_no_results_text": "Intente ajustar el período de tiempo.",
|
||||
"conv_swap_btn": "Intercambiar personas",
|
||||
|
||||
@@ -13,6 +13,7 @@ interface Props {
|
||||
suggestedName?: string;
|
||||
placeholder?: string;
|
||||
compact?: boolean;
|
||||
large?: boolean;
|
||||
restrictToCorrespondentsOf?: string;
|
||||
onchange?: (value: string) => void;
|
||||
onfocused?: () => void;
|
||||
@@ -26,6 +27,7 @@ let {
|
||||
suggestedName = '',
|
||||
placeholder,
|
||||
compact = false,
|
||||
large = false,
|
||||
restrictToCorrespondentsOf,
|
||||
onchange,
|
||||
onfocused
|
||||
@@ -140,9 +142,11 @@ function selectPerson(person: Person) {
|
||||
oninput={handleInput}
|
||||
onfocus={handleFocus}
|
||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||
class={compact
|
||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
class={large
|
||||
? 'mt-2 block h-14 w-full rounded-md border border-line bg-surface px-4 text-base text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: compact
|
||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
/>
|
||||
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
|
||||
90
frontend/src/routes/briefwechsel/CorrespondenzHero.svelte
Normal file
90
frontend/src/routes/briefwechsel/CorrespondenzHero.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
interface RecentPerson {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onSelectPerson: (id: string) => void;
|
||||
recentPersons?: RecentPerson[];
|
||||
}
|
||||
|
||||
const { onSelectPerson, recentPersons = [] }: Props = $props();
|
||||
|
||||
let senderId = $state('');
|
||||
let typeaheadEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
onMount(async () => {
|
||||
await tick();
|
||||
typeaheadEl?.querySelector<HTMLInputElement>('input[type="text"]')?.focus();
|
||||
});
|
||||
|
||||
function handlePersonChange(id: string) {
|
||||
if (id) {
|
||||
onSelectPerson(id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="conv-hero"
|
||||
class="mx-auto flex max-w-lg flex-col items-center gap-6 py-20 text-center"
|
||||
>
|
||||
<!-- Cross-link to document search -->
|
||||
<a href="/" class="text-sm text-ink-3 transition-colors hover:text-primary">
|
||||
{m.conv_hero_crosslink()}
|
||||
</a>
|
||||
|
||||
<!-- Discovery headline -->
|
||||
<h1 class="font-serif text-2xl font-black text-ink">
|
||||
{m.conv_empty_heading()}
|
||||
</h1>
|
||||
|
||||
<!-- Person typeahead (large, hero-sized) -->
|
||||
<div class="w-full max-w-sm" bind:this={typeaheadEl}>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
label="Person"
|
||||
large={true}
|
||||
bind:value={senderId}
|
||||
onchange={handlePersonChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Recent persons -->
|
||||
{#if recentPersons.length > 0}
|
||||
<div class="flex w-full max-w-sm items-center gap-2">
|
||||
<div class="flex-1 border-t border-line"></div>
|
||||
<span class="text-xs font-bold tracking-wider text-ink-3 uppercase">oder</span>
|
||||
<div class="flex-1 border-t border-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full max-w-sm flex-col items-center gap-3">
|
||||
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.conv_empty_recent_label()}
|
||||
</span>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
{#each recentPersons as person (person.id)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="recent-person-{person.id}"
|
||||
onclick={() => onSelectPerson(person.id)}
|
||||
class="flex items-center gap-2 rounded-full border border-line bg-surface px-4 py-2 text-sm font-bold text-ink transition-colors hover:border-primary hover:text-primary"
|
||||
>
|
||||
<span
|
||||
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary text-xs text-primary-fg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{person.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
{person.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import CorrespondenzHero from './CorrespondenzHero.svelte';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe('CorrespondenzHero — headline and cross-link', () => {
|
||||
it('renders the discovery headline', async () => {
|
||||
render(CorrespondenzHero, { onSelectPerson: noop });
|
||||
await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a cross-link to the document search page', async () => {
|
||||
render(CorrespondenzHero, { onSelectPerson: noop });
|
||||
const link = page.getByRole('link', { name: /Zur Dokumentensuche/i });
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
await expect.element(link).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('renders a person typeahead input', async () => {
|
||||
render(CorrespondenzHero, { onSelectPerson: noop });
|
||||
await expect.element(page.getByTestId('conv-hero').getByRole('textbox')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CorrespondenzHero — recent persons', () => {
|
||||
it('shows recent person chips when provided', async () => {
|
||||
render(CorrespondenzHero, {
|
||||
onSelectPerson: noop,
|
||||
recentPersons: [{ id: 'r1', name: 'Clara Braun' }]
|
||||
});
|
||||
await expect.element(page.getByText('Clara Braun')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSelectPerson when a recent person chip is clicked', async () => {
|
||||
const spy = vi.fn();
|
||||
render(CorrespondenzHero, {
|
||||
onSelectPerson: spy,
|
||||
recentPersons: [{ id: 'r1', name: 'Clara Braun' }]
|
||||
});
|
||||
await expect.element(page.getByText('Clara Braun')).toBeInTheDocument();
|
||||
document.querySelector<HTMLElement>('[data-testid="recent-person-r1"]')!.click();
|
||||
expect(spy).toHaveBeenCalledWith('r1');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user