feat(ui): two render states — hero vs results — with unified padding

Hero state (no senderId): centred CorrespondenzHero with discovery
headline, cross-link, large typeahead, recent persons. No person bar
or filter controls shown. Results state (senderId set): full-width
strips then content area with max-w-7xl responsive padding matching
other overview pages. Removes focus delegation hack.

Refs: #179

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-06 19:43:54 +02:00
parent e9acd44acb
commit f39d9e6f30
4 changed files with 126 additions and 87 deletions

View File

@@ -1,30 +1,26 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { untrack } from 'svelte';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import CorrespondenzPersonBar from './CorrespondenzPersonBar.svelte';
import CorrespondenzFilterControls from './CorrespondenzFilterControls.svelte';
import SinglePersonHintBar from './SinglePersonHintBar.svelte';
import ConversationTimeline from './ConversationTimeline.svelte';
import CorrespondenzEmptyState from './CorrespondenzEmptyState.svelte';
import CorrespondenzHero from './CorrespondenzHero.svelte';
import { m } from '$lib/paraglide/messages.js';
let { data } = $props();
// Filter values are local $state so swapPersons/toggleSort can mutate them before goto.
// They are initialised once from server data and never re-synced — navigation replaces
// the page component, so each load gets a fresh init.
let senderId = $state(untrack(() => data.filters.senderId));
let receiverId = $state(untrack(() => data.filters.receiverId));
let fromDate = $state(untrack(() => data.filters.from));
let toDate = $state(untrack(() => data.filters.to));
let sortDir = $state(untrack(() => data.filters.dir));
// Names are pure reads of server data — no local mutation needed.
const senderName = $derived(data.initialValues.senderName);
const receiverName = $derived(data.initialValues.receiverName);
// Side-effect only: persist the resolved sender to localStorage once the name is available.
$effect(() => {
if (data.filters.senderId && data.initialValues.senderName) {
persistRecentPerson(data.filters.senderId, data.initialValues.senderName);
@@ -36,11 +32,29 @@ const isSinglePerson = $derived(!!senderId && !receiverId);
const RECENT_STORAGE_KEY = 'korrespondenz_recent_persons';
const MAX_RECENT = 5;
interface RecentPerson {
id: string;
name: string;
}
let recentPersons = $state<RecentPerson[]>([]);
onMount(() => {
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
if (raw) {
recentPersons = JSON.parse(raw) as RecentPerson[];
}
} catch {
recentPersons = [];
}
});
function persistRecentPerson(id: string, name: string) {
if (!id) return;
try {
const raw = localStorage.getItem(RECENT_STORAGE_KEY);
const existing: { id: string; name: string }[] = raw ? JSON.parse(raw) : [];
const existing: RecentPerson[] = raw ? JSON.parse(raw) : [];
const filtered = existing.filter((p) => p.id !== id);
const updated = [{ id, name }, ...filtered].slice(0, MAX_RECENT);
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(updated));
@@ -72,69 +86,66 @@ function swapPersons() {
}
function selectPerson(id: string) {
if (!id) {
document.querySelector<HTMLInputElement>('#senderId-search')?.focus();
return;
}
senderId = id;
receiverId = '';
applyFilters();
}
</script>
<!-- Strips — pulled up to negate main's py-6 top padding so they sit flush -->
<div class="-mt-6">
<!-- Strip: Row 1 — full width, no container -->
<CorrespondenzPersonBar
bind:senderId={senderId}
bind:receiverId={receiverId}
initialSenderName={data.initialValues.senderName}
initialReceiverName={data.initialValues.receiverName}
onapplyFilters={applyFilters}
onswapPersons={swapPersons}
/>
<!-- Strip: Row 2 — full width -->
<CorrespondenzFilterControls
senderId={senderId}
bind:fromDate={fromDate}
bind:toDate={toDate}
bind:sortDir={sortDir}
documentCount={data.documents.length}
onapplyFilters={applyFilters}
ontoggleSort={toggleSort}
/>
<!-- Single-person hint bar -->
{#if isSinglePerson}
<SinglePersonHintBar
senderName={senderName}
fromDate={fromDate || undefined}
toDate={toDate || undefined}
sortDir={sortDir}
{#if !senderId}
<!-- Hero state: centred discovery view -->
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<CorrespondenzHero onSelectPerson={selectPerson} recentPersons={recentPersons} />
</div>
{:else}
<!-- Results state: strips + content -->
<div class="-mt-6">
<CorrespondenzPersonBar
bind:senderId={senderId}
bind:receiverId={receiverId}
initialSenderName={data.initialValues.senderName}
initialReceiverName={data.initialValues.receiverName}
onapplyFilters={applyFilters}
onswapPersons={swapPersons}
/>
{/if}
</div>
<!-- Content area with padding -->
<div class="px-[18px] py-[14px]">
{#if !senderId}
<CorrespondenzEmptyState onSelectPerson={selectPerson} />
{:else if data.documents.length === 0}
<div
class="flex flex-col items-center justify-center rounded-sm border border-line bg-muted py-24 text-center shadow-sm"
>
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
</div>
{:else}
<ConversationTimeline
documents={data.documents}
<CorrespondenzFilterControls
senderId={senderId}
receiverId={receiverId}
canWrite={data.canWrite}
senderName={senderName}
receiverName={receiverName}
bind:fromDate={fromDate}
bind:toDate={toDate}
bind:sortDir={sortDir}
documentCount={data.documents.length}
onapplyFilters={applyFilters}
ontoggleSort={toggleSort}
/>
{/if}
</div>
{#if isSinglePerson}
<SinglePersonHintBar
senderName={senderName}
fromDate={fromDate || undefined}
toDate={toDate || undefined}
sortDir={sortDir}
/>
{/if}
</div>
<div class="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
{#if data.documents.length === 0}
<div
class="flex flex-col items-center justify-center rounded-sm border border-line bg-muted py-24 text-center shadow-sm"
>
<p class="font-serif text-ink">{m.conv_no_results_heading()}</p>
<p class="mt-2 text-sm text-ink-3">{m.conv_no_results_text()}</p>
</div>
{:else}
<ConversationTimeline
documents={data.documents}
senderId={senderId}
receiverId={receiverId}
canWrite={data.canWrite}
senderName={senderName}
receiverName={receiverName}
/>
{/if}
</div>
{/if}

View File

@@ -27,6 +27,7 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
</script>
<div
data-testid="conv-filter-controls"
class="flex items-center gap-[10px] border-b border-line bg-muted px-4 py-[5px] transition-opacity sm:px-[18px]"
class:opacity-40={!senderId}
class:pointer-events-none={!senderId}

View File

@@ -53,7 +53,10 @@ function handleSuggestionSelect(id: string) {
}
</script>
<div class="flex items-end gap-[9px] border-b border-line bg-surface px-4 py-[9px] sm:px-[18px]">
<div
data-testid="conv-person-bar"
class="flex items-end gap-[9px] border-b border-line bg-surface px-4 py-[9px] sm:px-[18px]"
>
<!-- Person A -->
<div class="min-w-0 flex-1">
<PersonTypeahead

View File

@@ -53,17 +53,29 @@ const withDocs = {
documents: [makeDoc()]
};
// ─── Empty state (no senderId) ────────────────────────────────────────────────
// ─── Hero state (no senderId) ────────────────────────────────────────────────
describe('Korrespondenz page empty state', () => {
it('shows the search heading when no person is selected', async () => {
describe('Briefwechsel page hero state', () => {
it('shows the hero when no person is selected', async () => {
render(Page, { data: baseData });
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument();
});
it('shows the empty-search button', async () => {
it('shows the discovery headline', async () => {
render(Page, { data: baseData });
await expect.element(page.getByTestId('conv-empty-search')).toBeInTheDocument();
await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument();
});
it('does not show the person bar in hero state', async () => {
render(Page, { data: baseData });
await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument();
await expect.element(page.getByTestId('conv-person-bar')).not.toBeInTheDocument();
});
it('does not show filter controls in hero state', async () => {
render(Page, { data: baseData });
await expect.element(page.getByTestId('conv-hero')).toBeInTheDocument();
await expect.element(page.getByTestId('conv-filter-controls')).not.toBeInTheDocument();
});
it('does not show the new document link when no person is selected', async () => {
@@ -77,9 +89,29 @@ describe('Korrespondenz page empty state', () => {
});
});
// ─── Results state (senderId set) ────────────────────────────────────────────
describe('Briefwechsel page results state', () => {
it('does not show the hero when senderId is set', async () => {
render(Page, { data: withSender });
await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument();
await expect.element(page.getByTestId('conv-hero')).not.toBeInTheDocument();
});
it('shows the person bar when senderId is set', async () => {
render(Page, { data: withSender });
await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument();
});
it('shows filter controls when senderId is set', async () => {
render(Page, { data: withSender });
await expect.element(page.getByTestId('conv-filter-controls')).toBeInTheDocument();
});
});
// ─── Recent persons chips ─────────────────────────────────────────────────────
describe('Korrespondenz page recent persons', () => {
describe('Briefwechsel page recent persons', () => {
it('shows recent person chips from localStorage', async () => {
localStorage.setItem(
'korrespondenz_recent_persons',
@@ -93,15 +125,14 @@ describe('Korrespondenz page recent persons', () => {
it('does not crash when localStorage contains corrupt JSON', async () => {
localStorage.setItem('korrespondenz_recent_persons', '}{not valid json');
render(Page, { data: baseData });
// Empty state heading is still shown — no chip list crash
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
await expect.element(page.getByText(/Wessen Briefe möchten Sie lesen/i)).toBeInTheDocument();
localStorage.removeItem('korrespondenz_recent_persons');
});
});
// ─── Single-person hint bar ───────────────────────────────────────────────────
describe('Korrespondenz page single-person hint bar', () => {
describe('Briefwechsel page single-person hint bar', () => {
it('shows hint bar when only senderId is set', async () => {
render(Page, { data: withSender });
await expect.element(page.getByText(/Alle Briefe von Hans Müller/i)).toBeInTheDocument();
@@ -120,13 +151,7 @@ describe('Korrespondenz page single-person hint bar', () => {
// ─── Filter controls disabled state ──────────────────────────────────────────
describe('Korrespondenz page filter strip Row 2 disabled state', () => {
it('renders filter controls with aria-disabled when no senderId', async () => {
render(Page, { data: baseData });
const strip = document.querySelector('[aria-disabled="true"]');
expect(strip).not.toBeNull();
});
describe('Briefwechsel page filter strip Row 2 disabled state', () => {
it('filter controls are not aria-disabled when senderId is set', async () => {
render(Page, { data: withSender });
const strip = document.querySelector('[aria-disabled="false"]');
@@ -136,7 +161,7 @@ describe('Korrespondenz page filter strip Row 2 disabled state', () => {
// ─── Strip letter count ───────────────────────────────────────────────────────
describe('Korrespondenz page strip letter count', () => {
describe('Briefwechsel page strip letter count', () => {
it('shows 0 Briefe when senderId is set but no documents', async () => {
render(Page, { data: withSender });
await expect.element(page.getByTestId('conv-strip-count')).toHaveTextContent('0 Briefe');
@@ -150,7 +175,7 @@ describe('Korrespondenz page strip letter count', () => {
// ─── No results ───────────────────────────────────────────────────────────────
describe('Korrespondenz page no results', () => {
describe('Briefwechsel page no results', () => {
it('shows "no documents found" when a person is selected but there are no documents', async () => {
render(Page, { data: withSender });
await expect.element(page.getByText(/Keine Dokumente gefunden/i)).toBeInTheDocument();
@@ -159,12 +184,11 @@ describe('Korrespondenz page no results', () => {
// ─── Swap button ──────────────────────────────────────────────────────────────
describe('Korrespondenz page swap button', () => {
describe('Briefwechsel page swap button', () => {
it('swap button is invisible when only one person is set', async () => {
render(Page, { data: withSender });
const btn = document.querySelector<HTMLElement>('[data-testid="conv-swap-btn"]');
expect(btn).not.toBeNull();
// opacity-0 is applied via class when swapVisible is false
expect(btn!.className).toMatch(/opacity-0/);
});
@@ -187,7 +211,7 @@ describe('Korrespondenz page swap button', () => {
// ─── Year dividers ────────────────────────────────────────────────────────────
describe('Korrespondenz page year dividers', () => {
describe('Briefwechsel page year dividers', () => {
it('renders a year divider for the first document', async () => {
render(Page, { data: withDocs });
await expect.element(page.getByTestId('year-divider').first()).toHaveTextContent('1923');
@@ -222,7 +246,7 @@ describe('Korrespondenz page year dividers', () => {
// ─── New document link ────────────────────────────────────────────────────────
describe('Korrespondenz page new document link', () => {
describe('Briefwechsel page new document link', () => {
it('shows the link with correct href for a write user (bilateral)', async () => {
render(Page, { data: { ...withDocs, canWrite: true } });
const link = page.getByTestId('conv-new-doc-link');