feat(tag-input): tree-aware DFS ordering, depth indentation, and direct-match styling
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m37s
CI / Backend Unit Tests (push) Failing after 2m39s
CI / Unit & Component Tests (pull_request) Failing after 2m28s
CI / Backend Unit Tests (pull_request) Failing after 2m41s

Rewrites orderedSuggestions to a recursive DFS with SuggestionEntry type,
adds role=listbox, depth indentation via inline style, font-medium for direct
matches, text-ink-3 for context nodes, and › prefix for root-level ancestors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-17 11:54:28 +02:00
parent d075bf390a
commit 5120dd19a1
2 changed files with 159 additions and 51 deletions

View File

@@ -6,6 +6,8 @@ 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;
@@ -16,37 +18,39 @@ 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 suggestionsById = $derived(
new SvelteMap(suggestions.filter((s) => s.id).map((s) => [s.id!, s]))
);
const orderedSuggestions = $derived.by(() => {
const orderedSuggestions = $derived.by((): SuggestionEntry[] => {
const byId = new SvelteMap(suggestions.filter((s) => s.id).map((s) => [s.id!, s]));
const childrenOf = new SvelteMap<string, Tag[]>();
const roots: Tag[] = [];
const childrenMap = new SvelteMap<string, Tag[]>();
const orphans: Tag[] = [];
for (const s of suggestions) {
if (!s.parentId) {
roots.push(s);
} else if (suggestionsById.has(s.parentId)) {
const children = childrenMap.get(s.parentId) ?? [];
children.push(s);
childrenMap.set(s.parentId, children);
if (s.parentId && byId.has(s.parentId)) {
const arr = childrenOf.get(s.parentId) ?? [];
arr.push(s);
childrenOf.set(s.parentId, arr);
} else {
orphans.push(s);
roots.push(s);
}
}
const result: Tag[] = [];
for (const root of roots) {
result.push(root);
const children = childrenMap.get(root.id!) ?? [];
result.push(...children);
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);
}
}
result.push(...orphans);
for (const root of roots) {
dfs(root, 0);
}
return result;
});
@@ -62,6 +66,7 @@ async function fetchSuggestions(query: string) {
const currentTags = untrack(() => tags);
const currentNames = new Set(currentTags.map((t) => t.name));
suggestions = data.filter((t) => !currentNames.has(t.name));
fetchedForQuery = query.toLowerCase();
showSuggestions = true;
}
} catch (e) {
@@ -97,7 +102,7 @@ function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && orderedSuggestions[activeIndex]) {
addTag(orderedSuggestions[activeIndex]);
addTag(orderedSuggestions[activeIndex].tag);
} else if (allowCreation) {
addTag(inputVal);
}
@@ -153,34 +158,52 @@ function handleKeydown(e: KeyboardEvent) {
<input
type="text"
bind:value={inputVal}
oninput={() => { fetchSuggestions(inputVal); onTextInput?.(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()
: ''}
? 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 suggestion, i (suggestion.id ?? suggestion.name)}
{#each orderedSuggestions as s, i (s.tag.id ?? s.tag.name)}
<li
role="option"
aria-selected={i === activeIndex}
tabindex="0"
class="cursor-pointer px-3 py-2 text-sm hover:bg-muted {i === activeIndex
? 'bg-muted font-bold text-ink'
: 'text-ink-2'} {suggestion.parentId && suggestionsById.has(suggestion.parentId) ? 'pl-6' : ''}"
onclick={() => addTag(suggestion)}
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
style="padding-left: {s.depth * 16 + 12}px"
class="cursor-pointer py-2 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)}
>
{suggestion.name}
{#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>

View File

@@ -1,11 +1,21 @@
import { describe, expect, it, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page, userEvent } from 'vitest/browser';
import TagInput from './TagInput.svelte';
import TagInput, { type Tag } from './TagInput.svelte';
const waitForDebounce = () => new Promise((r) => setTimeout(r, 350));
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',
@@ -221,27 +231,102 @@ describe('TagInput autocomplete', () => {
});
it('shows child suggestion after its parent when both are in results', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'p1', name: 'Eltern' },
{ id: 'c1', name: 'Kind', parentId: 'p1' }
])
})
);
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 waitForDebounce();
const options = document.querySelectorAll('[role="option"]');
const names = Array.from(options).map((el) => el.textContent?.trim());
const elternIdx = names.indexOf('Eltern');
const kindIdx = names.indexOf('Kind');
expect(elternIdx).toBeGreaterThanOrEqual(0);
expect(kindIdx).toBeGreaterThanOrEqual(0);
expect(elternIdx).toBeLessThan(kindIdx);
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 waitForDebounce();
await expect.element(page.getByRole('listbox')).toBeInTheDocument();
});
});
// ─── 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 waitForDebounce();
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 waitForDebounce();
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 waitForDebounce();
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 waitForDebounce();
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');
});
});