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:
133
frontend/src/lib/timeline/TimelineFilters.svelte
Normal file
133
frontend/src/lib/timeline/TimelineFilters.svelte
Normal 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>
|
||||
74
frontend/src/lib/timeline/TimelineFilters.svelte.spec.ts
Normal file
74
frontend/src/lib/timeline/TimelineFilters.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user