feat(geschichten): add DocumentFilterChip component with spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -13,6 +13,9 @@ node_modules
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Leftover directory from branch work
|
||||||
|
/src.main/
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
35
frontend/src/routes/geschichten/DocumentFilterChip.svelte
Normal file
35
frontend/src/routes/geschichten/DocumentFilterChip.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
onremove
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
onremove: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const chipLabel = $derived(title ?? id.slice(0, 8));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-primary bg-primary px-3 text-primary-fg"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span class="font-sans text-xs tracking-wider whitespace-nowrap uppercase">
|
||||||
|
{m.geschichten_filter_document_chip()}
|
||||||
|
</span>
|
||||||
|
<span class="line-clamp-2 font-serif italic sm:max-w-[16rem] sm:truncate" title={chipLabel}>
|
||||||
|
{chipLabel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onremove}
|
||||||
|
aria-label={m.geschichten_filter_remove_document_chip({ title: chipLabel })}
|
||||||
|
class="ml-0.5 flex min-h-[44px] min-w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
import DocumentFilterChip from './DocumentFilterChip.svelte';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const VALID_UUID = '11111111-2222-3333-4444-555555555555';
|
||||||
|
|
||||||
|
describe('DocumentFilterChip', () => {
|
||||||
|
it('renders the resolved document title inside the chip', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: {
|
||||||
|
id: VALID_UUID,
|
||||||
|
title: 'Brief an Oma',
|
||||||
|
onremove: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Brief an Oma/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the prefix label', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to short UUID when title is null', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: null, onremove: vi.fn() }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/11111111/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onremove when the remove button is clicked', async () => {
|
||||||
|
const onremove = vi.fn();
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: 'Brief an Oma', onremove }
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = (await page
|
||||||
|
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
|
||||||
|
.element()) as HTMLElement;
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(onremove).toHaveBeenCalledOnce());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove button aria-label references the resolved title', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = page.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ });
|
||||||
|
await expect.element(btn).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title= attribute equals the validated id, not a raw query string', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||||
|
});
|
||||||
|
|
||||||
|
const chip = document.querySelector('[title]');
|
||||||
|
expect(chip?.getAttribute('title')).toBe('Brief an Oma');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove button has a minimum 44px touch target', async () => {
|
||||||
|
render(DocumentFilterChip, {
|
||||||
|
props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() }
|
||||||
|
});
|
||||||
|
|
||||||
|
const btn = (await page
|
||||||
|
.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ })
|
||||||
|
.element()) as HTMLElement;
|
||||||
|
expect(btn.className).toMatch(/min-h-\[44px\]|min-h-11/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user