From 5120dd19a1da185597238af9bd7ec3d052da154f Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 17 Apr 2026 11:54:28 +0200 Subject: [PATCH] feat(tag-input): tree-aware DFS ordering, depth indentation, and direct-match styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/lib/components/TagInput.svelte | 89 ++++++++----- .../lib/components/TagInput.svelte.spec.ts | 121 +++++++++++++++--- 2 files changed, 159 insertions(+), 51 deletions(-) diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index 2dc13e6c..ac8204e5 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -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(); const roots: Tag[] = []; - const childrenMap = new SvelteMap(); - 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) { { 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" /> {#if showSuggestions && orderedSuggestions.length > 0}
    - {#each orderedSuggestions as suggestion, i (suggestion.id ?? suggestion.name)} + {#each orderedSuggestions as s, i (s.tag.id ?? s.tag.name)}
  • 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} + + {/if} + {#if s.tag.color} + + {/if} + {s.tag.name}
  • {/each}
diff --git a/frontend/src/lib/components/TagInput.svelte.spec.ts b/frontend/src/lib/components/TagInput.svelte.spec.ts index 4a269a8d..73f51d45 100644 --- a/frontend/src/lib/components/TagInput.svelte.spec.ts +++ b/frontend/src/lib/components/TagInput.svelte.spec.ts @@ -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'); }); });