# Tag Typeahead — Tree-Aware Results

When a parent tag matches a search query, its children appear below it. When a child tag matches, its ancestor chain appears above it as navigational context. All returned items remain selectable.

Design Spec
API contract
No change — GET /api/tags still returns List<Tag>. Backend enriches the flat list.
Enrichment scope
Root match → all descendants via recursive CTE. Child match → all ancestors via recursive CTE.
Visual treatment
Direct matches: full color + font-medium. Context nodes: text-ink-3 + chevron prefix for roots.
Selectability
Every item in the list is selectable — context ancestors are valid tags, not decorations.
Scale notice: All font sizes, heights, and paddings in the mockups below are at approximately 55 % of the real implementation values. Annotations and the implementation reference tables at the bottom of each section show the real Tailwind classes and pixel values to use.
1 Before vs. After — the two gaps this closes
Before Parent matches — children missing
familienarchiv / dokument / bearbeiten
FAMILIENARCHIVDokumentePersonen
Tags
Brief
Briefe direct match
Kinder von "Briefe" nicht sichtbar — obwohl relevant
Current: only "Briefe" returned. Children like "Familienbriefe" and "Weihnachtsbriefe" are invisible unless the user already knows their exact names.
After Parent matches — children shown below
familienarchiv / dokument / bearbeiten
FAMILIENARCHIVDokumentePersonen
Tags
Brief
Briefe ↩ auswählen
Familienbriefe
Weihnachtsbriefe
Kinder jetzt sichtbar und wählbar
After: "Briefe" shown prominently; its children appear indented below in muted style. User can select any of them directly.
Before Child matches — ancestor path missing
familienarchiv / dokument / bearbeiten
FAMILIENARCHIVDokumentePersonen
Tags
Hochzeit
Hochzeit
Silberhochzeit
Unklar, ob "Hochzeit" das richtige Tag ist
Current: results appear without hierarchy context. User cannot tell which "Hochzeit" belongs where in the tree.
After Child matches — ancestor shown above for context
familienarchiv / dokument / bearbeiten
FAMILIENARCHIVDokumentePersonen
Tags
Hochzeit
› Ereignisse
Hochzeit ↩ auswählen
Silberhochzeit
Goldene Hochzeit
"Ereignisse" als Kontext, alle Geschwister sichtbar
After: "Ereignisse" appears first as muted context (with › prefix). "Hochzeit" sits indented below — the hierarchy is immediately clear. Sibling tags are also visible for quick selection.
2 Multi-level path — up to 3 levels deep
User types "gold" — match is 3 levels deep
familienarchiv / dokument / bearbeiten
FAMILIENARCHIVDokumentePersonen
Tags
gold
› Ereignisse
Hochzeit
Goldene Hochzeit ↩ auswählen
Backend returns: Goldene Hochzeit (direct match) + Hochzeit + Ereignisse (ancestors). Frontend DFS produces depth 0 → 1 → 2 ordering. Indent = depth × 16px + 12px base.
Keyboard navigation across all depths
familienarchiv / dokument / bearbeiten
FAMILIENARCHIVDokumentePersonen
Tags
gold
› Ereignisse ↩ auswählen
Hochzeit
Goldene Hochzeit
Arrow keys move through ALL items regardless of depth or match status. Pressing Enter on a context node (e.g. "Ereignisse") adds it as a tag — context nodes are fully selectable.
DFS ordering guarantee: The frontend builds a tree from the flat API response, then does a depth-first walk starting from roots (tags whose parentId is absent from the result set). This ensures parents always appear before their children, at any depth, regardless of the order the backend returns items.
3 Mixed results — two independent subtrees in one dropdown
User types "post" — matches across two unrelated subtrees
familienarchiv / dokument / bearbeiten
FAMILIENARCHIVDokumentePersonen
Tags
post
Post
Pakete
Postkarten ↩ auswählen
› Finanzen
Postbank
Two independent subtrees interleaved cleanly. DFS ensures each subtree's items are grouped together. The visual divider (1px line) between unrelated subtrees is cosmetic and optional.
Already-selected tags are filtered from suggestions
familienarchiv / dokument / bearbeiten
FAMILIENARCHIVDokumentePersonen
Tags
Post ×
post
Pakete Kind von Post
Postkarten ↩ auswählen
› Finanzen
Postbank
When "Post" is already selected as a chip, it's filtered from the dropdown. Its children remain visible (they're still useful), but the parent row is gone. The filter runs on tag names, same as today.
Context nodes of a filtered parent: If the matched direct-tag is already selected (filtered), its children can appear as "orphans" (parentId not found in result set). They should still appear — just not indented. The DFS puts them at depth 0 since their parent was removed. This is acceptable; the user sees the available children and can select them.
4 Backend implementation reference
Implementation Reference — §4 Backend TagService.java · no new endpoint · no DTO change

What | How | Notes
--- | --- | ---
File | `backend/src/main/java/…/service/TagService.java` | Modify existing `search(String query)` method only. No new endpoint, no new DTO, no API type regeneration needed.
Step 1 — name search | `List<Tag> matched = tagRepository.findByNameContainingIgnoreCase(query);` | Existing query, unchanged. Returns early if empty.

Step 2 — collect extra IDs

    Set<UUID> extraIds = new HashSet<>();
    for (Tag t : matched) {
        if (t.getParentId() == null) {
            // root match → expand descendants
            extraIds.addAll(tagRepository.findDescendantIds(t.getId()));
        } else {
            // child match → walk up to root
            extraIds.addAll(tagRepository.findAncestorIds(t.getId()));
        }
    }
    // remove IDs we already have to avoid duplicate fetch
    Set<UUID> matchedIds = matched.stream()
        .map(Tag::getId).collect(Collectors.toSet());
    extraIds.removeAll(matchedIds);

`findDescendantIds` and `findAncestorIds` are recursive CTEs already in `TagRepository`, depth-guarded at 50 levels. Both return `List<UUID>` including the seed ID — removing matched IDs deduplicates cleanly.

Step 3 — batch fetch & merge

    List<Tag> all = new ArrayList<>(matched);
    if (!extraIds.isEmpty()) {
        all.addAll(tagRepository.findAllById(extraIds));
    }
    resolveEffectiveColors(all);
    return all;

`tagRepository.findAllById()` is inherited from `JpaRepository` — one IN-clause query. `resolveEffectiveColors()` already exists in TagService; it populates inherited color from parent for child tags.

Query count | 1 (name search) + N (CTE per matched tag) + 1 (batch fetch) | For a typical query matching 1–3 tags: 3–5 queries total. CTEs are recursive but depth-guarded; negligible cost for typical tag trees of depth ≤ 4.
Return type | `List<Tag>` — unchanged | No API contract change. Frontend Tag type (`{ id, name, color?, parentId? }`) already carries `parentId`. No `generate:api` run needed.

Unit tests to add

`TagServiceTest.java`

1. `search_includes_children_when_root_matches()` — mock root match, mock `findDescendantIds`, assert children in result.
2. `search_includes_ancestors_when_child_matches()` — mock child match with parentId, mock `findAncestorIds`, assert parent in result.
3. `search_deduplicates_when_ancestor_also_matches()` — parent matches AND child matches; ancestor not duplicated in result.

5 Frontend implementation reference
Implementation Reference — §5 TagInput.svelte frontend/src/lib/components/TagInput.svelte

What | How | Notes
--- | --- | ---

New type alias

    type SuggestionEntry = {
        tag: Tag;
        depth: number;
        isDirectMatch: boolean;
    };

Replace the current `Tag[]` return type of `orderedSuggestions` with `SuggestionEntry[]`. The `Tag` type is already imported from the local definition at the top of the file.

Direct match set

    const directMatchIds = $derived(
        new Set(
            suggestions
                .filter(s => s.id &&
                    s.name.toLowerCase()
                        .includes(inputVal.toLowerCase()))
                .map(s => s.id!)
        )
    );

A tag is a "direct match" if its name contains the current input text (case-insensitive). Tags added by backend enrichment (ancestors/descendants) will not match, so `isDirectMatch` will be false for them.

Replace orderedSuggestions

    const orderedSuggestions = $derived.by((): SuggestionEntry[] => {
        const byId = new Map(
            suggestions.filter(s => s.id).map(s => [s.id!, s])
        );
        const childrenOf = new Map<string, Tag[]>();
        for (const s of suggestions) {
            if (s.parentId && byId.has(s.parentId)) {
                const list = childrenOf.get(s.parentId) ?? [];
                list.push(s);
                childrenOf.set(s.parentId, list);
            }
        }
        // roots = tags whose parent is not in this result set
        const roots = suggestions.filter(
            s => !s.parentId || !byId.has(s.parentId)
        );
        const result: SuggestionEntry[] = [];

        function walk(tag: Tag, depth: number) {
            result.push({
                tag,
                depth,
                isDirectMatch: directMatchIds.has(tag.id!)
            });
            for (const child of childrenOf.get(tag.id!) ?? []) {
                walk(child, depth + 1);
            }
        }
        for (const root of roots) walk(root, 0);
        return result;
    });

Replaces the existing 3-bucket (roots/childrenMap/orphans) logic. The DFS handles unlimited depth. The "orphan" concept disappears — items whose parent is absent become roots at depth 0.

Update handleKeydown

    // Before: addTag(orderedSuggestions[activeIndex])
    // After:
    addTag(orderedSuggestions[activeIndex].tag)

The `activeIndex` logic itself is unchanged. Only the access pattern changes: `.tag` to unwrap the `SuggestionEntry`.

Dropdown template item

    {#each orderedSuggestions as { tag: s, depth, isDirectMatch }, i (s.id ?? s.name)}
      <li
        role="option"
        aria-selected={i === activeIndex}
        tabindex="0"
        style="padding-left: {depth * 16 + 12}px"
        class="cursor-pointer py-2 pr-3 text-sm border-b border-line-2
               last:border-b-0
               {i === activeIndex
                   ? 'bg-muted font-bold text-ink'
                   : isDirectMatch
                       ? 'text-ink font-medium'
                       : 'text-ink-3'}
               hover:bg-muted"
        onclick={() => addTag(s)}
        onkeydown={(e) => e.key === 'Enter' && addTag(s)}
      >
        {#if !isDirectMatch && depth === 0}
          <span class="mr-1 text-ink-3">›</span>
        {/if}
        {#if s.color}
          <span
            style="background-color: var(--c-tag-{s.color})"
            class="inline-block h-2 w-2 rounded-full mr-1 opacity-{isDirectMatch ? '100' : '50'}"
          ></span>
        {/if}
        {s.name}
      </li>
    {/each}

Indent formula: `depth × 16 + 12` px (inline style — Tailwind dynamic classes not viable for variable values).
Context nodes at depth 0 (ancestors without a parent in results) get a `›` prefix to signal "this is context, not a primary result".
Color dot opacity: 100% for direct matches, 50% for context nodes.

Remove pl-6 class | Delete the old conditional `{suggestion.parentId && suggestionsById.has(suggestion.parentId) ? 'pl-6' : ''}` | Indentation is now handled entirely by the `style="padding-left: …"` inline style based on computed depth. The old `suggestionsById` derived map can also be removed as it's no longer needed.
Depth indentation | depth 0 → 12px · depth 1 → 28px · depth 2 → 44px | Formula: `depth * 16 + 12`. Maximum realistic depth in this app is 3–4 levels. No truncation needed.
Context node style | `text-ink-3` (#6b7280) · no font-weight override | Context nodes (not a direct match) use the muted gray. When hovered or keyboard-active, they get `bg-muted font-bold text-ink` — same as any other item. They are fully clickable.
i18n keys | None new required. | The feature is purely visual. All existing `comp_taginput_*` keys remain applicable.
Frontend tests to update | `TagInput.svelte.spec.ts` | 1. Update existing "shows child after parent" test — it now expects `SuggestionEntry` depth/match info. 2. Add: ancestor shown above direct match with `text-ink-3`. 3. Add: 3-level path renders in correct DFS order. 4. Add: keyboard Enter on context node adds it as a tag.