feat(timeline): add TimelineFilters presentation component

A dumb, client-side layer-filter bar for /zeitstrahl: three $bindable layer
toggles (Personal/Historical/Letters) in a fieldset/legend, a sticky
"Filter (N aktiv)" trigger driven by hiddenLayerCount, and a reset text button
shown only when a layer is off. Toggles mirror the SearchFilterBar undated-toggle
markup (aria-pressed, ✓ glyph, 44px touch target, semantic tokens). The
collapsible slide honours prefers-reduced-motion by zeroing its duration. No
goto, no fetch — the route owns the state and the filtered view.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-14 20:26:57 +02:00
committed by marcel
parent 182d014971
commit c6fe61f06b
2 changed files with 207 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { slide } from 'svelte/transition';
import { hiddenLayerCount, isDefaultState, type TimelineLayerFilters } from './timelineFilter';
// Presentation-only layer filter for the global /zeitstrahl (#780, REQ-001).
// Holds no timeline data and never navigates or fetches — the route owns the
// $state and derives the filtered view. Three $bindable layer booleans plus an
// onChange notification hook are the whole contract.
let {
personalOn = $bindable(true),
historicalOn = $bindable(true),
lettersOn = $bindable(true),
onChange
}: {
personalOn?: boolean;
historicalOn?: boolean;
lettersOn?: boolean;
onChange?: () => void;
} = $props();
let open = $state(false);
// Reuse the reduced-motion guard expression from documents/[id]/+page.svelte:57
// for a new purpose — zeroing the slide duration so the collapsible opens
// instantly when the reader prefers reduced motion (REQ-009).
const prefersReducedMotion = $derived(
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
const slideDuration = $derived(prefersReducedMotion ? 0 : 200);
const filters: TimelineLayerFilters = $derived({ personalOn, historicalOn, lettersOn });
const hiddenCount = $derived(hiddenLayerCount(filters));
const anyLayerOff = $derived(!isDefaultState(filters));
function reset() {
personalOn = true;
historicalOn = true;
lettersOn = true;
onChange?.();
}
</script>
{#snippet layerToggle(label: string, testid: string, pressed: boolean, toggle: () => void)}
<button
type="button"
data-testid={testid}
aria-pressed={pressed}
onclick={toggle}
class="inline-flex min-h-[44px] items-center gap-2 rounded border px-3 font-sans text-sm transition-colors {pressed
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-muted text-ink-2 hover:bg-line'}"
>
<span
aria-hidden="true"
class="inline-flex h-4 w-4 items-center justify-center rounded-sm border {pressed
? 'border-primary-fg bg-primary-fg/20'
: 'border-ink-3'}"
>
{#if pressed}{/if}
</span>
{label}
</button>
{/snippet}
<section class="mb-6">
<!-- Sticky trigger kept in document flow so the hidden-layer count stays
visible without clipping timeline content (REQ-007). -->
<div class="sticky top-16 z-20 bg-canvas py-2">
<button
type="button"
data-testid="timeline-filter-trigger"
aria-expanded={open}
aria-controls={open ? 'timeline-filter-panel' : undefined}
onclick={() => (open = !open)}
class="inline-flex min-h-[44px] items-center gap-2 rounded border border-line bg-surface px-4 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase transition-colors hover:bg-muted"
>
{hiddenCount === 0
? m.timeline_filter_trigger()
: m.timeline_filter_trigger_active({ count: hiddenCount })}
</button>
</div>
{#if open}
<div id="timeline-filter-panel" transition:slide={{ duration: slideDuration }}>
<fieldset class="mt-2 rounded-sm border border-line bg-surface p-4">
<legend class="px-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.timeline_filter_label_layers()}
</legend>
<div class="flex flex-wrap gap-2">
{@render layerToggle(
m.timeline_filter_layer_personal(),
'timeline-filter-personal',
personalOn,
() => {
personalOn = !personalOn;
onChange?.();
}
)}
{@render layerToggle(
m.timeline_filter_layer_historical(),
'timeline-filter-historical',
historicalOn,
() => {
historicalOn = !historicalOn;
onChange?.();
}
)}
{@render layerToggle(
m.timeline_filter_layer_letters(),
'timeline-filter-letters',
lettersOn,
() => {
lettersOn = !lettersOn;
onChange?.();
}
)}
</div>
{#if anyLayerOff}
<button
type="button"
data-testid="timeline-filter-reset"
onclick={reset}
class="mt-3 inline-flex min-h-[44px] items-center font-sans text-sm text-primary underline-offset-2 hover:underline"
>
{m.timeline_filter_reset()}
</button>
{/if}
</fieldset>
</div>
{/if}
</section>

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import * as m from '$lib/paraglide/messages.js';
import TimelineFilters from './TimelineFilters.svelte';
afterEach(() => cleanup());
const allOn = () => ({ personalOn: true, historicalOn: true, lettersOn: true, onChange: vi.fn() });
async function openBar() {
await page.getByTestId('timeline-filter-trigger').click();
}
describe('TimelineFilters', () => {
it('renders the three layer toggles with accessible names inside a labelled group (REQ-001)', async () => {
render(TimelineFilters, allOn());
await openBar();
await expect
.element(page.getByRole('button', { name: m.timeline_filter_layer_personal() }))
.toBeVisible();
await expect
.element(page.getByRole('button', { name: m.timeline_filter_layer_historical() }))
.toBeVisible();
await expect
.element(page.getByRole('button', { name: m.timeline_filter_layer_letters() }))
.toBeVisible();
// the fieldset legend groups the toggles
await expect.element(page.getByText(m.timeline_filter_label_layers())).toBeVisible();
});
it('reflects a layer as pressed and flips it, firing onChange (REQ-001)', async () => {
const props = allOn();
render(TimelineFilters, props);
await openBar();
const personal = page.getByTestId('timeline-filter-personal');
await expect.element(personal).toHaveAttribute('aria-pressed', 'true');
await personal.click();
await expect.element(personal).toHaveAttribute('aria-pressed', 'false');
expect(props.onChange).toHaveBeenCalled();
});
it('shows a plain trigger when all layers are on and a count once a layer is hidden (REQ-007/010)', async () => {
render(TimelineFilters, allOn());
const trigger = page.getByTestId('timeline-filter-trigger');
await expect.element(trigger).toHaveTextContent(m.timeline_filter_trigger());
await expect.element(trigger).not.toHaveTextContent('aktiv');
await trigger.click();
await page.getByTestId('timeline-filter-letters').click();
await expect.element(trigger).toHaveTextContent(m.timeline_filter_trigger_active({ count: 1 }));
});
it('gives the trigger a 44px touch target (REQ-007)', async () => {
render(TimelineFilters, allOn());
await expect.element(page.getByTestId('timeline-filter-trigger')).toHaveClass(/min-h-\[44px\]/);
});
it('hides the reset button by default and restores all layers when activated (REQ-008)', async () => {
const props = allOn();
render(TimelineFilters, props);
await openBar();
const reset = page.getByTestId('timeline-filter-reset');
// absent (not just hidden) while every layer is on
expect(reset.query()).toBeNull();
await page.getByTestId('timeline-filter-historical').click();
await expect.element(reset).toBeVisible();
await reset.click();
await expect
.element(page.getByTestId('timeline-filter-historical'))
.toHaveAttribute('aria-pressed', 'true');
await expect.poll(() => reset.query()).toBeNull();
expect(props.onChange).toHaveBeenCalled();
});
});