refactor: move tag domain components to lib/tag/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
let { tags, max = 3 }: { tags: Tag[]; max?: number } = $props();
|
||||
|
||||
const displayedTags = $derived(tags.slice(0, max));
|
||||
const hiddenTagCount = $derived(Math.max(0, tags.length - max));
|
||||
</script>
|
||||
|
||||
{#if tags.length > 0}
|
||||
<div class="flex flex-wrap items-center gap-1 pt-0.5">
|
||||
{#each displayedTags as tag (tag.id)}
|
||||
<span
|
||||
data-testid="thumb-row-tag"
|
||||
class="max-w-[140px] truncate rounded-full border border-line bg-surface px-2 py-0.5 text-sm text-ink-2"
|
||||
>{tag.name}</span
|
||||
>
|
||||
{/each}
|
||||
{#if hiddenTagCount > 0}
|
||||
<span class="text-sm font-bold text-ink-3">+{hiddenTagCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
|
||||
import TagChipList from './TagChipList.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeTags = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({ id: `t${i}`, name: `Tag${i}` }));
|
||||
|
||||
describe('TagChipList', () => {
|
||||
it('renders all tags as chips when under the cap', () => {
|
||||
render(TagChipList, { tags: makeTags(2), max: 3 });
|
||||
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||
expect(chips).toHaveLength(2);
|
||||
expect(document.body.textContent).not.toMatch(/\+/);
|
||||
});
|
||||
|
||||
it('caps visible chips at max and renders +N for the remainder', () => {
|
||||
render(TagChipList, { tags: makeTags(5), max: 3 });
|
||||
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||
expect(chips).toHaveLength(3);
|
||||
expect(document.body.textContent).toMatch(/\+2/);
|
||||
});
|
||||
|
||||
it('renders nothing when tags is empty', () => {
|
||||
render(TagChipList, { tags: [], max: 3 });
|
||||
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||
expect(chips).toHaveLength(0);
|
||||
expect(document.body.textContent).not.toMatch(/\+/);
|
||||
});
|
||||
|
||||
it('defaults max to 3 when the prop is omitted', () => {
|
||||
render(TagChipList, { tags: makeTags(5) });
|
||||
const chips = document.querySelectorAll('[data-testid="thumb-row-tag"]');
|
||||
expect(chips).toHaveLength(3);
|
||||
expect(document.body.textContent).toMatch(/\+2/);
|
||||
});
|
||||
});
|
||||
@@ -1,219 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
export type Tag = { id?: string; name: string; color?: string; parentId?: string };
|
||||
|
||||
type SuggestionEntry = { tag: Tag; depth: number; isDirectMatch: boolean };
|
||||
|
||||
interface Props {
|
||||
tags?: Tag[];
|
||||
allowCreation?: boolean;
|
||||
onTextInput?: (text: string) => void;
|
||||
}
|
||||
|
||||
let { tags = $bindable([]), allowCreation = true, onTextInput }: Props = $props();
|
||||
|
||||
let inputVal = $state('');
|
||||
let suggestions: Tag[] = $state([]);
|
||||
let fetchedForQuery = $state('');
|
||||
let activeIndex = $state(-1);
|
||||
let showSuggestions = $state(false);
|
||||
|
||||
const orderedSuggestions = $derived.by((): SuggestionEntry[] => {
|
||||
// SvelteMap satisfies svelte/prefer-svelte-reactivity; reactivity comes from $derived.by() re-evaluation
|
||||
const byId = new SvelteMap(suggestions.filter((s) => s.id).map((s) => [s.id!, s]));
|
||||
const childrenOf = new SvelteMap<string, Tag[]>();
|
||||
const roots: Tag[] = [];
|
||||
|
||||
for (const s of suggestions) {
|
||||
if (s.parentId && byId.has(s.parentId)) {
|
||||
const arr = childrenOf.get(s.parentId) ?? [];
|
||||
arr.push(s);
|
||||
childrenOf.set(s.parentId, arr);
|
||||
} else {
|
||||
roots.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
const query = fetchedForQuery;
|
||||
const result: SuggestionEntry[] = [];
|
||||
|
||||
function dfs(tag: Tag, depth: number) {
|
||||
result.push({ tag, depth, isDirectMatch: tag.name.toLowerCase().includes(query) });
|
||||
for (const child of childrenOf.get(tag.id!) ?? []) {
|
||||
dfs(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
dfs(root, 0);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
async function fetchSuggestions(query: string) {
|
||||
if (query.length < 2) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const data: Tag[] = await res.json();
|
||||
const currentTags = untrack(() => tags);
|
||||
const currentNames = new Set(currentTags.map((t) => t.name));
|
||||
suggestions = data.filter((t) => !currentNames.has(t.name));
|
||||
// fetchedForQuery set after suggestions: $derived fires twice but second run is correct
|
||||
fetchedForQuery = query.toLowerCase();
|
||||
showSuggestions = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Tag fetch error', e);
|
||||
}
|
||||
}
|
||||
|
||||
function addTag(tag: Tag | string) {
|
||||
const newTag: Tag = typeof tag === 'string' ? { name: tag.trim() } : tag;
|
||||
if (!newTag.name) return;
|
||||
const currentTags = untrack(() => tags);
|
||||
if (currentTags.some((t) => t.name === newTag.name)) {
|
||||
inputVal = '';
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
activeIndex = -1;
|
||||
onTextInput?.('');
|
||||
return;
|
||||
}
|
||||
tags = [...tags, newTag];
|
||||
inputVal = '';
|
||||
suggestions = [];
|
||||
showSuggestions = false;
|
||||
activeIndex = -1;
|
||||
onTextInput?.('');
|
||||
}
|
||||
|
||||
function removeTag(index: number) {
|
||||
tags = tags.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (activeIndex >= 0 && orderedSuggestions[activeIndex]) {
|
||||
addTag(orderedSuggestions[activeIndex].tag);
|
||||
} else if (allowCreation) {
|
||||
addTag(inputVal);
|
||||
}
|
||||
} else if (e.key === 'Backspace' && inputVal === '' && tags.length > 0) {
|
||||
removeTag(tags.length - 1);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % orderedSuggestions.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + orderedSuggestions.length) % orderedSuggestions.length;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full" use:clickOutside onclickoutside={() => (showSuggestions = false)}>
|
||||
<!-- Tag Container -->
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||
>
|
||||
<!-- Render Selected Tags -->
|
||||
{#each tags as tag, i (tag.id ?? tag.name)}
|
||||
<span class="flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm font-medium text-ink">
|
||||
{#if tag.color}
|
||||
<span
|
||||
data-testid="tag-color-dot"
|
||||
data-color={tag.color}
|
||||
style="background-color: var(--c-tag-{tag.color})"
|
||||
class="inline-block h-2 w-2 flex-shrink-0 rounded-full"
|
||||
></span>
|
||||
{/if}
|
||||
{tag.name}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeTag(i)}
|
||||
aria-label={m.comp_taginput_remove()}
|
||||
class="text-ink/50 hover:text-red-500 focus:outline-none"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/></svg
|
||||
>
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<!-- Input Field -->
|
||||
<div class="relative min-w-[120px] flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={inputVal}
|
||||
oninput={() => {
|
||||
fetchSuggestions(inputVal);
|
||||
onTextInput?.(inputVal);
|
||||
}}
|
||||
onkeydown={handleKeydown}
|
||||
onfocus={() => fetchSuggestions(inputVal)}
|
||||
placeholder={tags.length === 0
|
||||
? allowCreation
|
||||
? m.comp_taginput_placeholder_create()
|
||||
: m.comp_taginput_placeholder_filter()
|
||||
: ''}
|
||||
class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||
/>
|
||||
|
||||
<!-- Typeahead Dropdown -->
|
||||
{#if showSuggestions && orderedSuggestions.length > 0}
|
||||
<ul
|
||||
role="listbox"
|
||||
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-line bg-surface shadow-lg"
|
||||
>
|
||||
{#each orderedSuggestions as s, i (s.tag.id ?? s.tag.name)}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
tabindex="0"
|
||||
style="padding-left: {s.depth * 16 + 12}px"
|
||||
class="cursor-pointer py-3 pr-3 text-sm hover:bg-muted {i === activeIndex
|
||||
? 'bg-muted font-bold text-ink'
|
||||
: s.isDirectMatch
|
||||
? 'font-medium text-ink-2'
|
||||
: 'text-ink-3'}"
|
||||
onclick={() => addTag(s.tag)}
|
||||
onkeydown={(e) => e.key === 'Enter' && addTag(s.tag)}
|
||||
>
|
||||
<!-- › only on root context ancestors; depth>0 nodes rely on indentation instead -->
|
||||
{#if !s.isDirectMatch && s.depth === 0}
|
||||
<span class="mr-1" aria-hidden="true">›</span>
|
||||
{/if}
|
||||
{#if s.tag.color}
|
||||
<span
|
||||
style="background-color: var(--c-tag-{s.tag.color})"
|
||||
class="mr-1 inline-block h-2 w-2 flex-shrink-0 rounded-full {s.isDirectMatch
|
||||
? 'opacity-100'
|
||||
: 'opacity-50'}"
|
||||
></span>
|
||||
{/if}
|
||||
{s.tag.name}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if allowCreation}
|
||||
<p class="mt-1 text-xs text-ink-3">{m.comp_taginput_create_hint()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,376 +0,0 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import TagInput, { type Tag } from './TagInput.svelte';
|
||||
|
||||
const waitForFetch = () => new Promise((r) => setTimeout(r, 50));
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
function mockFetchWithTagObjects(tags: Tag[]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(tags)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchWithTags(tagNames: string[]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(tagNames.map((name) => ({ name })))
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function mockFetchEmpty() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – rendering', () => {
|
||||
it('shows creation placeholder when allowCreation=true and no tags', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
await expect.element(page.getByPlaceholder('Schlagworte hinzufügen...')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-empty.png' });
|
||||
});
|
||||
|
||||
it('shows filter placeholder when allowCreation=false', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
await expect.element(page.getByPlaceholder('Nach Schlagworten filtern...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders existing tags as chips', async () => {
|
||||
render(TagInput, { tags: [{ name: 'Familie' }, { name: 'Krieg' }], allowCreation: true });
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-with-chips.png' });
|
||||
});
|
||||
|
||||
it('hides input placeholder once tags exist', async () => {
|
||||
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await expect.element(input).toHaveAttribute('placeholder', '');
|
||||
});
|
||||
|
||||
it('shows the "Enter" hint when allowCreation=true', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
await expect.element(page.getByText(/Enter drücken/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the "Enter" hint when allowCreation=false', async () => {
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
await expect.element(page.getByText(/Enter drücken/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a color dot on chips that have a color', async () => {
|
||||
render(TagInput, { tags: [{ name: 'Familie', color: 'sage' }], allowCreation: true });
|
||||
const dot = page.getByTestId('tag-color-dot');
|
||||
await expect.element(dot).toBeInTheDocument();
|
||||
await expect.element(dot).toHaveAttribute('data-color', 'sage');
|
||||
});
|
||||
|
||||
it('does not render a color dot on chips without a color', async () => {
|
||||
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||
await expect.element(page.getByTestId('tag-color-dot')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Adding tags ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – adding tags', () => {
|
||||
it('adds a tag on Enter and clears the input', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Urlaubsreise');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('Urlaubsreise')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
});
|
||||
|
||||
it('trims whitespace from the new tag', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill(' Leerzeichen ');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('Leerzeichen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add a duplicate tag', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Familie');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(input).toHaveValue('');
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add an arbitrary tag when allowCreation=false', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('UnbekannterTag');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('UnbekannterTag')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Removing tags ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – removing tags', () => {
|
||||
it('removes a chip when its × button is clicked', async () => {
|
||||
render(TagInput, { tags: [{ name: 'Familie' }, { name: 'Krieg' }], allowCreation: true });
|
||||
// The × buttons have aria-label="Schlagwort entfernen"
|
||||
document.querySelector<HTMLElement>('button[aria-label="Schlagwort entfernen"]')!.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Familie')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Krieg')).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-after-remove.png' });
|
||||
});
|
||||
|
||||
it('removes the last tag on Backspace when the input is empty', async () => {
|
||||
render(TagInput, { tags: [{ name: 'Familie' }, { name: 'Krieg' }], allowCreation: true });
|
||||
(document.querySelector('input[type="text"]') as HTMLInputElement).focus();
|
||||
await userEvent.keyboard('{Backspace}');
|
||||
await expect.element(page.getByText('Krieg')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not remove a tag on Backspace when the input has text', async () => {
|
||||
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('x');
|
||||
await userEvent.keyboard('{Backspace}');
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Autocomplete ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – autocomplete', () => {
|
||||
it('shows suggestions after typing 2+ characters', async () => {
|
||||
mockFetchWithTags(['Familie', 'Freunde']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForFetch();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestions.png' });
|
||||
});
|
||||
|
||||
it('does not call fetch for fewer than 2 characters', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('F');
|
||||
await waitForFetch();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters already-selected tags out of suggestions', async () => {
|
||||
mockFetchWithTags(['Familie', 'Freunde']);
|
||||
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fr');
|
||||
await waitForFetch();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
||||
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects a suggestion on click and adds it as a chip', async () => {
|
||||
mockFetchWithTags(['Familie', 'Freunde']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForFetch();
|
||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||
await tick();
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestion-selected.png' });
|
||||
});
|
||||
|
||||
it('navigates suggestions with ArrowDown and selects with Enter', async () => {
|
||||
mockFetchWithTags(['Aachen', 'Berlin', 'Celle']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('__');
|
||||
await waitForFetch();
|
||||
await userEvent.keyboard('{ArrowDown}'); // index 0 → Aachen
|
||||
await userEvent.keyboard('{ArrowDown}'); // index 1 → Berlin
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the dropdown when clicking outside the component', async () => {
|
||||
mockFetchWithTags(['Familie']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForFetch();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
|
||||
document.body.click();
|
||||
await tick();
|
||||
await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows child suggestion after its parent when both are in results', async () => {
|
||||
mockFetchWithTagObjects([
|
||||
{ id: 'p1', name: 'Eltern' },
|
||||
{ id: 'c1', name: 'Kind', parentId: 'p1' }
|
||||
]);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('El');
|
||||
await waitForFetch();
|
||||
const options = Array.from(document.querySelectorAll('[role="option"]'));
|
||||
const texts = options.map((el) => el.textContent?.replace(/›/g, '').trim() ?? '');
|
||||
expect(texts.indexOf('Eltern')).toBeLessThan(texts.indexOf('Kind'));
|
||||
// direct match (Eltern contains 'El') has font-medium; context child does not
|
||||
const elternEl = options.find((el) => el.textContent?.replace(/›/g, '').trim() === 'Eltern');
|
||||
expect(elternEl?.classList.contains('font-medium')).toBe(true);
|
||||
});
|
||||
|
||||
it('dropdown has role listbox', async () => {
|
||||
mockFetchWithTags(['Familie']);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForFetch();
|
||||
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('selects a suggestion via Enter when allowCreation=false', async () => {
|
||||
mockFetchWithTags(['Familie']);
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForFetch();
|
||||
await userEvent.keyboard('{ArrowDown}'); // index 0 → Familie
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||
await expect.element(input).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tree-aware ordering ──────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – tree-aware ordering', () => {
|
||||
it('shows children below match when root tag matches in DFS order', async () => {
|
||||
mockFetchWithTagObjects([
|
||||
{ id: 'r1', name: 'Briefe' },
|
||||
{ id: 'c1', name: 'Familienbriefe', parentId: 'r1' },
|
||||
{ id: 'g1', name: 'Weihnachtsbriefe', parentId: 'c1' }
|
||||
]);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Brief');
|
||||
await waitForFetch();
|
||||
const options = Array.from(document.querySelectorAll('[role="option"]'));
|
||||
const texts = options.map((el) => el.textContent?.replace(/›/g, '').trim() ?? '');
|
||||
expect(texts).toContain('Briefe');
|
||||
expect(texts).toContain('Familienbriefe');
|
||||
expect(texts).toContain('Weihnachtsbriefe');
|
||||
expect(texts.indexOf('Briefe')).toBeLessThan(texts.indexOf('Familienbriefe'));
|
||||
expect(texts.indexOf('Familienbriefe')).toBeLessThan(texts.indexOf('Weihnachtsbriefe'));
|
||||
});
|
||||
|
||||
it('shows ancestor above match when child tag matches', async () => {
|
||||
mockFetchWithTagObjects([
|
||||
{ id: 'r1', name: 'Briefe' },
|
||||
{ id: 'c1', name: 'Hochzeit', parentId: 'r1' }
|
||||
]);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Hochzeit');
|
||||
await waitForFetch();
|
||||
const options = Array.from(document.querySelectorAll('[role="option"]'));
|
||||
const texts = options.map((el) => el.textContent?.replace(/›/g, '').trim() ?? '');
|
||||
expect(texts.indexOf('Briefe')).toBeLessThan(texts.indexOf('Hochzeit'));
|
||||
const hochzeitEl = options.find(
|
||||
(el) => el.textContent?.replace(/›/g, '').trim() === 'Hochzeit'
|
||||
);
|
||||
expect(hochzeitEl?.classList.contains('font-medium')).toBe(true);
|
||||
});
|
||||
|
||||
it('keyboard Enter on context node adds it as a tag', async () => {
|
||||
mockFetchWithTagObjects([
|
||||
{ id: 'r1', name: 'Briefe' },
|
||||
{ id: 'c1', name: 'Hochzeit', parentId: 'r1' }
|
||||
]);
|
||||
render(TagInput, { tags: [], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Hochzeit');
|
||||
await waitForFetch();
|
||||
await userEvent.keyboard('{ArrowDown}'); // first item: Briefe (context ancestor)
|
||||
await userEvent.keyboard('{Enter}');
|
||||
// successful add clears the input; if .tag unwrap is missing, addTag returns early
|
||||
await expect.element(input).toHaveValue('');
|
||||
});
|
||||
|
||||
it('shows orphaned children at depth 0 when their parent is already selected', async () => {
|
||||
mockFetchWithTagObjects([
|
||||
{ id: 'p1', name: 'Briefe' },
|
||||
{ id: 'c1', name: 'Familienbriefe', parentId: 'p1' }
|
||||
]);
|
||||
render(TagInput, { tags: [{ id: 'p1', name: 'Briefe' }], allowCreation: true });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Fa');
|
||||
await waitForFetch();
|
||||
await expect.element(page.getByRole('option', { name: 'Familienbriefe' })).toBeInTheDocument();
|
||||
// the already-selected parent must not appear as a suggestion
|
||||
const optionNames = Array.from(document.querySelectorAll('[role="option"]')).map(
|
||||
(el) => el.textContent?.replace(/›/g, '').trim() ?? ''
|
||||
);
|
||||
expect(optionNames).not.toContain('Briefe');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── onTextInput callback ──────────────────────────────────────────────────────
|
||||
|
||||
describe('TagInput – onTextInput callback', () => {
|
||||
it('calls onTextInput with the current value on every input event', async () => {
|
||||
mockFetchEmpty();
|
||||
const onTextInput = vi.fn();
|
||||
render(TagInput, { tags: [], allowCreation: false, onTextInput });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('fa');
|
||||
await expect.poll(() => onTextInput.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(onTextInput).toHaveBeenCalledWith('fa');
|
||||
});
|
||||
|
||||
it('does not throw when onTextInput is not provided', async () => {
|
||||
mockFetchEmpty();
|
||||
render(TagInput, { tags: [], allowCreation: false });
|
||||
const input = page.getByRole('textbox');
|
||||
await expect(input.fill('fa')).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('calls onTextInput with empty string when a tag chip is added', async () => {
|
||||
mockFetchWithTags(['Kaufvertrag']);
|
||||
const onTextInput = vi.fn();
|
||||
render(TagInput, { tags: [], allowCreation: false, onTextInput });
|
||||
const input = page.getByRole('textbox');
|
||||
await input.fill('Ka');
|
||||
await waitForFetch();
|
||||
const option = page.getByRole('option', { name: 'Kaufvertrag' });
|
||||
await option.click();
|
||||
await expect.poll(() => onTextInput.mock.calls.at(-1)).toEqual(['']);
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
||||
|
||||
type Tag = components['schemas']['Tag'];
|
||||
|
||||
interface FlatTagRef {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
value?: string;
|
||||
excludeIds?: string[];
|
||||
initialName?: string;
|
||||
allTags?: FlatTagRef[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
name,
|
||||
value = $bindable(''),
|
||||
excludeIds = [],
|
||||
initialName = '',
|
||||
allTags = [],
|
||||
placeholder = m.admin_tag_parent_placeholder()
|
||||
}: Props = $props();
|
||||
|
||||
// displayName must be both prop-derived AND locally writable (user typing), so $state +
|
||||
// $effect is the correct pattern here — writable $derived is read-only and won't work.
|
||||
// eslint-disable-next-line svelte/prefer-writable-derived
|
||||
let displayName = $state(initialName);
|
||||
|
||||
$effect(() => {
|
||||
displayName = initialName;
|
||||
});
|
||||
|
||||
// Uses fetch directly (not the typed api client) because this component runs in the browser
|
||||
// where the typed api client is not available, and the tags endpoint needs no auth cookie.
|
||||
const typeahead = createTypeahead<Tag>({
|
||||
fetchUrl: async (q) => {
|
||||
const res = await fetch(`/api/tags?query=${encodeURIComponent(q)}`);
|
||||
return res.ok ? await res.json() : [];
|
||||
},
|
||||
debounceMs: 300
|
||||
});
|
||||
|
||||
const filteredResults = $derived(typeahead.results.filter((t) => !excludeIds.includes(t.id)));
|
||||
|
||||
function handleInput() {
|
||||
const term = untrack(() => displayName);
|
||||
typeahead.setQuery(term);
|
||||
// Reset active index whenever results are re-fetched
|
||||
typeahead.setActiveIndex(-1);
|
||||
}
|
||||
|
||||
function selectTag(tag: Tag) {
|
||||
value = tag.id;
|
||||
displayName = tag.name;
|
||||
typeahead.close();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
value = '';
|
||||
displayName = '';
|
||||
typeahead.close();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (!typeahead.isOpen) return;
|
||||
const len = filteredResults.length;
|
||||
if (len === 0) return;
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
typeahead.setActiveIndex((typeahead.activeIndex + 1) % len);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
typeahead.setActiveIndex((typeahead.activeIndex - 1 + len) % len);
|
||||
} else if (e.key === 'Enter' && typeahead.activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
selectTag(filteredResults[typeahead.activeIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
typeahead.close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
|
||||
<input type="hidden" name={name} bind:value={value} />
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="{name}-search"
|
||||
autocomplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={typeahead.isOpen}
|
||||
aria-controls="{name}-listbox"
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={typeahead.activeIndex >= 0
|
||||
? `${name}-option-${typeahead.activeIndex}`
|
||||
: undefined}
|
||||
bind:value={displayName}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={placeholder}
|
||||
class="mt-1 block w-full rounded-md border border-line bg-surface p-2 pr-8 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
|
||||
{#if value}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearSelection}
|
||||
aria-label="Auswahl entfernen"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2 rounded-sm text-ink-3 hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if typeahead.isOpen && filteredResults.length > 0}
|
||||
<div
|
||||
id="{name}-listbox"
|
||||
role="listbox"
|
||||
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
|
||||
>
|
||||
{#each filteredResults as tag, i (tag.id)}
|
||||
<div
|
||||
id="{name}-option-{i}"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
aria-selected={i === typeahead.activeIndex}
|
||||
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg {i === typeahead.activeIndex ? 'bg-accent-bg' : ''}"
|
||||
onclick={() => selectTag(tag)}
|
||||
onkeydown={(e) => e.key === 'Enter' && selectTag(tag)}
|
||||
>
|
||||
<span class="block truncate font-medium">{tag.name}</span>
|
||||
{#if tag.parentId}
|
||||
{@const parentName = allTags.find((t) => t.id === tag.parentId)?.name ?? tag.parentId}
|
||||
<span class="block truncate text-xs text-ink-3">{parentName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,202 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TagParentPicker from './TagParentPicker.svelte';
|
||||
|
||||
function hiddenInput(name: string) {
|
||||
return document.querySelector<HTMLInputElement>(`input[type="hidden"][name="${name}"]`);
|
||||
}
|
||||
|
||||
function mockFetchWithTags(tags: { id: string; name: string; parentId?: string }[]) {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(tags)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagParentPicker – rendering', () => {
|
||||
it('renders the text input', async () => {
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hidden input with correct name', async () => {
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hiddenInput('parentId')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses custom placeholder text when provided', async () => {
|
||||
render(TagParentPicker, { name: 'target', placeholder: 'Ziel-Schlagwort suchen …' });
|
||||
const input = await page.getByRole('combobox').element();
|
||||
expect(input.getAttribute('placeholder')).toBe('Ziel-Schlagwort suchen …');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagParentPicker – search', () => {
|
||||
it('typing shows dropdown results', async () => {
|
||||
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('H');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
await expect.element(page.getByText('Haus')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters excludeIds from results', async () => {
|
||||
mockFetchWithTags([
|
||||
{ id: 't1', name: 'Haus' },
|
||||
{ id: 't2', name: 'Garten' }
|
||||
]);
|
||||
render(TagParentPicker, { name: 'parentId', excludeIds: ['t1'] });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('a');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
await expect.element(page.getByText('Haus')).not.toBeInTheDocument();
|
||||
await expect.element(page.getByText('Garten')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Selection ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagParentPicker – selection', () => {
|
||||
it('selecting an option sets the hidden input value', async () => {
|
||||
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('H');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
await page.getByRole('option', { name: 'Haus' }).click();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(hiddenInput('parentId')?.value).toBe('t1');
|
||||
});
|
||||
|
||||
it('clear button appears when value is set', async () => {
|
||||
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('H');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
await page.getByRole('option', { name: 'Haus' }).click();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Auswahl entfernen' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clear button resets value', async () => {
|
||||
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('H');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
await page.getByRole('option', { name: 'Haus' }).click();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(hiddenInput('parentId')?.value).toBe('t1');
|
||||
|
||||
await page.getByRole('button', { name: 'Auswahl entfernen' }).click();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(hiddenInput('parentId')?.value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ARIA combobox ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('TagParentPicker – ARIA combobox', () => {
|
||||
it('ArrowDown moves aria-activedescendant to first option', async () => {
|
||||
mockFetchWithTags([
|
||||
{ id: 't1', name: 'Haus' },
|
||||
{ id: 't2', name: 'Garten' }
|
||||
]);
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('a');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
// Dropdown is open — arrow down should highlight first option
|
||||
const el = await input.element();
|
||||
el.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true })
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(el.getAttribute('aria-activedescendant')).toBe('parentId-option-0');
|
||||
});
|
||||
|
||||
it('listbox items have role="option" with ids', async () => {
|
||||
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
|
||||
render(TagParentPicker, { name: 'parentId' });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('H');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
await expect.element(page.getByRole('option', { name: 'Haus' })).toBeInTheDocument();
|
||||
const option = await page.getByRole('option', { name: 'Haus' }).element();
|
||||
expect(option.id).toBe('parentId-option-0');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Parent name resolution ───────────────────────────────────────────────────
|
||||
|
||||
describe('TagParentPicker – parent name subtitle', () => {
|
||||
it('shows parent name instead of UUID when allTags is provided', async () => {
|
||||
mockFetchWithTags([{ id: 't2', name: 'Keller', parentId: 't1' }]);
|
||||
const allTags = [
|
||||
{ id: 't1', name: 'Haus', documentCount: 5 },
|
||||
{ id: 't2', name: 'Keller', parentId: 't1', documentCount: 2 }
|
||||
];
|
||||
render(TagParentPicker, { name: 'parentId', allTags });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('K');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
await expect.element(page.getByText('Haus')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows nothing as subtitle when tag has no parentId', async () => {
|
||||
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
|
||||
const allTags = [{ id: 't1', name: 'Haus', documentCount: 5 }];
|
||||
render(TagParentPicker, { name: 'parentId', allTags });
|
||||
|
||||
const input = page.getByRole('combobox');
|
||||
await input.fill('H');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
// Only the tag name should appear (no subtitle)
|
||||
await expect.element(page.getByRole('option', { name: 'Haus' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user