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
DokumentePersonen
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
DokumentePersonen
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
DokumentePersonen
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
DokumentePersonen
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
DokumentePersonen
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
DokumentePersonen
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
DokumentePersonen
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
DokumentePersonen
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
WhatHowNotes
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
WhatHowNotes
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.