feat(ui): collapsible date filter with sort + filter toggle on person row
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m13s
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 1m10s
CI / Backend Unit Tests (pull_request) Failing after 2m33s

Move sort button and filter toggle to the person row, matching the
document search page pattern (sort + filter + count inline). Date
range inputs are now a collapsible section behind the filter toggle,
using slide transition and the same grid layout as the document
search advanced filters. Fix date input padding (add px-3).

Refs: #179

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-06 22:34:14 +02:00
parent c4715f1637
commit c8b4bce003
4 changed files with 124 additions and 119 deletions

View File

@@ -28,6 +28,7 @@ $effect(() => {
});
const isSinglePerson = $derived(!!senderId && !receiverId);
let showAdvanced = $state(false);
const RECENT_STORAGE_KEY = 'korrespondenz_recent_persons';
const MAX_RECENT = 5;
@@ -107,20 +108,23 @@ function selectPerson(id: string) {
bind:receiverId={receiverId}
initialSenderName={data.initialValues.senderName}
initialReceiverName={data.initialValues.receiverName}
onapplyFilters={applyFilters}
onswapPersons={swapPersons}
/>
<CorrespondenzFilterControls
senderId={senderId}
bind:fromDate={fromDate}
bind:toDate={toDate}
bind:sortDir={sortDir}
sortDir={sortDir}
showAdvanced={showAdvanced}
documentCount={data.documents.length}
onapplyFilters={applyFilters}
onswapPersons={swapPersons}
ontoggleSort={toggleSort}
ontoggleAdvanced={() => (showAdvanced = !showAdvanced)}
/>
{#if showAdvanced}
<CorrespondenzFilterControls
bind:fromDate={fromDate}
bind:toDate={toDate}
onapplyFilters={applyFilters}
/>
{/if}
{#if isSinglePerson}
<SinglePersonHintBar
senderName={senderName}

View File

@@ -1,116 +1,50 @@
<script lang="ts">
import { slide } from 'svelte/transition';
import { m } from '$lib/paraglide/messages.js';
import DateInput from '$lib/components/DateInput.svelte';
interface Props {
senderId: string;
fromDate?: string;
toDate?: string;
sortDir?: string;
documentCount?: number;
onapplyFilters: () => void;
ontoggleSort: () => void;
}
let {
senderId,
fromDate = $bindable(''),
toDate = $bindable(''),
sortDir = $bindable('DESC'),
documentCount,
onapplyFilters,
ontoggleSort
}: Props = $props();
let hasDateFilter = $derived(!!(fromDate || toDate));
let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
let { fromDate = $bindable(''), toDate = $bindable(''), onapplyFilters }: Props = $props();
</script>
<div
data-testid="conv-filter-controls"
class="mt-6 flex items-center gap-4 border-t border-line-2 pt-6 transition-opacity"
class:opacity-40={!senderId}
class:pointer-events-none={!senderId}
aria-disabled={!senderId}
transition:slide
class="mt-6 grid grid-cols-1 gap-6 border-t border-line-2 pt-6 md:grid-cols-12"
>
<!-- Period label -->
<span class="text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.conv_strip_period()}
</span>
<!-- From date -->
<DateInput
bind:value={fromDate}
onchange={() => onapplyFilters()}
placeholder={m.conv_strip_from_placeholder()}
class="w-[120px] border-line py-2.5 text-sm text-ink shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {fromDate ? 'border-primary' : 'border-line'}"
/>
<span class="text-sm text-ink-3"></span>
<div class="md:col-span-3">
<label
for="conv-from"
class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase"
>
{m.conv_label_from()}
</label>
<DateInput
id="conv-from"
bind:value={fromDate}
onchange={() => onapplyFilters()}
placeholder={m.conv_strip_from_placeholder()}
class="block w-full border-line px-3 py-2.5 text-sm text-ink shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<!-- To date -->
<DateInput
bind:value={toDate}
onchange={() => onapplyFilters()}
placeholder={m.conv_strip_to_placeholder()}
class="w-[120px] border-line py-2.5 text-sm text-ink shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {toDate ? 'border-primary' : 'border-line'}"
/>
<!-- Document count -->
<span
data-testid="conv-strip-count"
class="ml-auto text-sm font-bold"
class:text-primary={hasDateFilter}
class:text-ink-3={!hasDateFilter}
>
{m.conv_letters_count({ count: documentCount ?? 0 })}
</span>
<!-- Sort button -->
<button
data-testid="conv-sort-btn"
type="button"
aria-label="Sortierung umkehren"
aria-pressed={sortDir === 'ASC'}
onclick={ontoggleSort}
class="flex items-center gap-2 border px-4 py-2.5 text-sm font-bold tracking-wide uppercase transition hover:bg-muted hover:text-ink"
class:border-primary={isActive}
class:text-primary={isActive}
class:border-line={!isActive}
class:text-ink-2={!isActive}
>
{#if sortDir === 'ASC'}
{m.conv_strip_sort_oldest()}
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="18 15 12 9 6 15" />
</svg>
{:else}
{m.conv_strip_sort_newest()}
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
{/if}
</button>
<div class="md:col-span-3">
<label for="conv-to" class="mb-2 block text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.conv_label_to()}
</label>
<DateInput
id="conv-to"
bind:value={toDate}
onchange={() => onapplyFilters()}
placeholder={m.conv_strip_to_placeholder()}
class="block w-full border-line px-3 py-2.5 text-sm text-ink shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
</div>

View File

@@ -1,14 +1,20 @@
<script lang="ts">
import PersonTypeahead from '$lib/components/PersonTypeahead.svelte';
import CorrespondentSuggestionsDropdown from './CorrespondentSuggestionsDropdown.svelte';
import { m } from '$lib/paraglide/messages.js';
interface Props {
senderId?: string;
receiverId?: string;
initialSenderName?: string;
initialReceiverName?: string;
sortDir?: string;
showAdvanced?: boolean;
documentCount?: number;
onapplyFilters: () => void;
onswapPersons: () => void;
ontoggleSort: () => void;
ontoggleAdvanced: () => void;
}
let {
@@ -16,8 +22,13 @@ let {
receiverId = $bindable(''),
initialSenderName = '',
initialReceiverName = '',
sortDir = 'DESC',
showAdvanced = false,
documentCount = 0,
onapplyFilters,
onswapPersons
onswapPersons,
ontoggleSort,
ontoggleAdvanced
}: Props = $props();
interface Correspondent {
@@ -53,6 +64,7 @@ function handleSuggestionSelect(id: string) {
}
</script>
<!-- Row 1: Person inputs -->
<div data-testid="conv-person-bar" class="flex items-end gap-4">
<!-- Person A -->
<div
@@ -127,3 +139,67 @@ function handleSuggestionSelect(id: string) {
{/if}
</div>
</div>
<!-- Row 2: Sort + Filter toggle + Count (mirrors document search bar pattern) -->
<div class="mt-4 flex items-center gap-4">
<!-- Sort button -->
<button
data-testid="conv-sort-btn"
type="button"
aria-label="Sortierung umkehren"
aria-pressed={sortDir === 'ASC'}
onclick={ontoggleSort}
class="flex items-center gap-2 border border-line bg-muted px-4 py-2.5 text-sm font-bold tracking-wide text-ink-2 uppercase transition hover:bg-muted hover:text-ink"
>
{#if sortDir === 'ASC'}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="h-4 w-4"><polyline points="18 15 12 9 6 15" /></svg
>
{m.conv_strip_sort_oldest()}
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="h-4 w-4"><polyline points="6 9 12 15 18 9" /></svg
>
{m.conv_strip_sort_newest()}
{/if}
</button>
<!-- Filter toggle button -->
<button
onclick={ontoggleAdvanced}
class="flex items-center gap-2 border border-line bg-muted px-4 py-2.5 text-sm font-bold tracking-wide text-ink-2 uppercase transition hover:bg-muted hover:text-ink"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Chevron/Chevron-Down-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 transform transition-transform duration-200 {showAdvanced ? 'rotate-180' : ''}"
/>
{m.docs_btn_filter()}
</button>
<!-- Document count -->
<span data-testid="conv-strip-count" class="ml-auto text-sm font-bold text-ink-3">
{m.conv_letters_count({ count: documentCount })}
</span>
</div>

View File

@@ -103,9 +103,10 @@ describe('Briefwechsel page results state', () => {
await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument();
});
it('shows filter controls when senderId is set', async () => {
it('hides filter controls by default (collapsible)', async () => {
render(Page, { data: withSender });
await expect.element(page.getByTestId('conv-filter-controls')).toBeInTheDocument();
await expect.element(page.getByTestId('conv-person-bar')).toBeInTheDocument();
await expect.element(page.getByTestId('conv-filter-controls')).not.toBeInTheDocument();
});
});
@@ -149,16 +150,6 @@ describe('Briefwechsel page single-person hint bar', () => {
});
});
// ─── Filter controls disabled state ──────────────────────────────────────────
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"]');
expect(strip).not.toBeNull();
});
});
// ─── Strip letter count ───────────────────────────────────────────────────────
describe('Briefwechsel page strip letter count', () => {