docs(specs): add tag-typeahead-tree-aware design spec
Visual spec for tree-aware tag typeahead: parent matches expand to show children, child matches surface ancestor path for context. Covers backend enrichment strategy (TagService.search enrichment via existing recursive CTEs) and frontend DFS ordering + depth-indent rendering in TagInput.svelte. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
760
docs/specs/tag-typeahead-tree-aware.html
Normal file
760
docs/specs/tag-typeahead-tree-aware.html
Normal file
@@ -0,0 +1,760 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tag Typeahead — Tree-Aware Results · Design Spec · Familienarchiv</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5}
|
||||
.doc{max-width:1300px;margin:0 auto;padding:48px 32px}
|
||||
|
||||
/* ── Masthead ─── */
|
||||
.mast{background:#0D2240;border-radius:10px;padding:32px 40px;margin-bottom:48px}
|
||||
.mast-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:16px}
|
||||
.mast h1{font-size:22px;font-weight:900;color:#fff;letter-spacing:-.4px;margin-bottom:6px}
|
||||
.mast p{font-size:12px;color:rgba(255,255,255,.5);max-width:640px;line-height:1.7}
|
||||
.mast-badge{font-size:9px;font-weight:800;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap;flex-shrink:0;margin-top:4px}
|
||||
.mb-final{background:#A6DAD8;color:#002850}
|
||||
.decisions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.1);padding-top:16px}
|
||||
.dec{background:rgba(255,255,255,.06);border-radius:6px;padding:10px 12px}
|
||||
.dec-label{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.35);margin-bottom:5px}
|
||||
.dec-value{font-size:9.5px;font-weight:700;color:#fff;line-height:1.5}
|
||||
|
||||
/* ── Section headings ─── */
|
||||
.sec{margin-bottom:64px}
|
||||
.sec+.sec{border-top:2px dashed #C8C4BE;padding-top:56px}
|
||||
.sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
|
||||
.sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
|
||||
.sec-num{background:#0D2240;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
|
||||
|
||||
/* ── Grid ─── */
|
||||
.sg{display:grid;gap:20px;align-items:start}
|
||||
.sg-2{grid-template-columns:1fr 1fr}
|
||||
.sg-3{grid-template-columns:1fr 1fr 1fr}
|
||||
.sb{display:flex;flex-direction:column}
|
||||
.sl{font-size:9px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
|
||||
.sc{font-size:10px;color:#888;margin-top:8px;font-style:italic;line-height:1.5}
|
||||
|
||||
/* ── Browser chrome ─── */
|
||||
.wf{background:#fff;border:2px solid #B8B4AE;border-radius:10px;overflow:visible;box-shadow:0 4px 18px rgba(0,0,0,.08);max-width:560px}
|
||||
.wf-inner{border-radius:10px;overflow:hidden}
|
||||
.wf-bar{height:24px;background:#E8E4DF;border-bottom:1px solid #C8C4BE;display:flex;align-items:center;padding:0 9px;gap:4px}
|
||||
.dot{width:7px;height:7px;border-radius:50%;background:#C8C4BE}
|
||||
.dot.r{background:#F87171}.dot.y{background:#FCD34D}.dot.g{background:#4ADE80}
|
||||
.urlbar{flex:1;height:11px;background:#D8D4CE;border-radius:3px;margin-left:6px;display:flex;align-items:center;padding:0 5px}
|
||||
.urlbar span{font-size:7.5px;color:#888;font-family:monospace}
|
||||
.N{height:38px;background:#0D2240;display:flex;align-items:center;padding:0 16px;gap:12px;flex-shrink:0}
|
||||
.logo{font-size:9px;font-weight:900;color:#fff;letter-spacing:1px}
|
||||
.nl{font-size:7.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.5px}
|
||||
.nl.on{color:#fff;border-bottom:2px solid #A6DAD8;padding-bottom:2px}
|
||||
.nr{margin-left:auto;display:flex;gap:7px;align-items:center}
|
||||
.MAIN{padding:14px 18px;background:#ECEAE4}
|
||||
|
||||
/* ── Tag chips ─── */
|
||||
.TAG-INPUT-WRAP{background:#fff;border:1.5px solid #e4e2d7;border-radius:3px;display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px 8px;min-height:34px}
|
||||
.TAG-INPUT-WRAP.focused{border-color:#012851;box-shadow:0 0 0 2px rgba(1,40,81,.07)}
|
||||
.TAG-CHIP{display:inline-flex;align-items:center;gap:4px;background:#f5f4ef;border-radius:3px;padding:2px 7px;font-size:7.5px;font-weight:600;color:#012851}
|
||||
.TAG-DOT{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
||||
.TAG-X{font-size:8px;color:#9ca3af;cursor:pointer;line-height:1;margin-left:1px}
|
||||
.TAG-INPUT{border:none;outline:none;background:transparent;font-size:7.5px;color:#012851;min-width:80px;padding:1px 3px}
|
||||
.TAG-INPUT::placeholder{color:#9ca3af}
|
||||
|
||||
/* ── Typeahead dropdown ─── */
|
||||
.DD{background:#fff;border:1.5px solid #e4e2d7;border-radius:3px;box-shadow:0 6px 14px rgba(0,0,0,.1);margin-top:3px;overflow:hidden}
|
||||
.DD-ITEM{display:flex;align-items:center;padding:5px 10px;font-size:8px;cursor:pointer;gap:5px;border-bottom:1px solid #f5f4ef}
|
||||
.DD-ITEM:last-child{border-bottom:none}
|
||||
.DD-ITEM.hover{background:#f5f4ef}
|
||||
.DD-ITEM.active{background:#f5f4ef;font-weight:700;color:#012851}
|
||||
.DD-ITEM.match{color:#012851;font-weight:600}
|
||||
.DD-ITEM.context{color:#6b7280}
|
||||
.DD-ITEM .dd-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
||||
.DD-ITEM .dd-chevron{color:#9ca3af;font-size:8px;margin-right:2px;flex-shrink:0}
|
||||
.DD-ITEM .dd-badge{font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:#9ca3af;margin-left:auto;flex-shrink:0}
|
||||
|
||||
/* ── Annotation callouts ─── */
|
||||
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:10px 14px;font-size:10.5px;color:#7C2D12;line-height:1.6;margin-top:14px}
|
||||
.ann-block strong{font-weight:800}
|
||||
.ann-block ul{padding-left:16px;display:flex;flex-direction:column;gap:3px;margin-top:6px}
|
||||
.ann-info{background:#EFF6FF;border:1px solid #BFDBFE;border-radius:5px;padding:10px 14px;font-size:10.5px;color:#1E3A5F;line-height:1.6;margin-top:14px}
|
||||
.ann-info strong{font-weight:800}
|
||||
.ann-ok{background:#F0FDF4;border:1px solid #86EFAC;border-radius:5px;padding:10px 14px;font-size:10.5px;color:#14532D;line-height:1.6;margin-top:14px}
|
||||
|
||||
/* ── Spec disclaimer ─── */
|
||||
.spec-disclaimer{background:#FFF8E1;border:1.5px solid #FFC107;border-radius:6px;padding:11px 16px;font-size:11px;color:#6D4C00;margin-bottom:32px;line-height:1.6}
|
||||
.spec-disclaimer strong{font-weight:800}
|
||||
|
||||
/* ── impl-ref ─── */
|
||||
.impl-ref{background:#0d1117;border-radius:8px;margin-top:20px;overflow:hidden;border:1px solid #30363d}
|
||||
.impl-ref-hdr{background:#161b22;padding:9px 16px;font-size:9.5px;font-weight:800;color:#f0883e;border-bottom:1px solid #30363d;display:flex;align-items:center;gap:8px;letter-spacing:.4px;text-transform:uppercase}
|
||||
.impl-ref-hdr::before{content:'⚙';font-size:12px}
|
||||
.impl-ref-hdr span{color:rgba(240,136,62,.55);font-weight:400;margin-left:auto;font-size:9px;text-transform:none;letter-spacing:0}
|
||||
.impl-ref table{width:100%;border-collapse:collapse;font-size:10px}
|
||||
.impl-ref th{text-align:left;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#8b949e;padding:8px 14px;border-bottom:1px solid #21262d}
|
||||
.impl-ref td{padding:6px 14px;border-bottom:1px solid #161b22;vertical-align:top;line-height:1.6;color:#c9d1d9}
|
||||
.impl-ref tr:last-child td{border-bottom:none}
|
||||
.impl-ref td:first-child{color:#79c0ff;font-weight:700;white-space:nowrap;width:220px}
|
||||
.impl-ref td code{font-family:'SFMono-Regular',Consolas,monospace;font-size:9.5px;background:#161b22;color:#a5d6ff;padding:1px 5px;border-radius:3px;white-space:nowrap}
|
||||
.impl-ref .ir-px{color:#7ee787;font-family:monospace;font-size:9.5px}
|
||||
.impl-ref td pre{font-family:'SFMono-Regular',Consolas,monospace;font-size:9px;color:#a5d6ff;background:#161b22;padding:6px 8px;border-radius:4px;margin-top:4px;line-height:1.7;white-space:pre-wrap}
|
||||
|
||||
/* ── State pill labels ─── */
|
||||
.state-label{display:inline-block;font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;border-radius:3px;padding:1px 5px;margin-bottom:5px}
|
||||
.st-before{background:#FEE2E2;color:#991B1B}
|
||||
.st-after{background:#DCFCE7;color:#166534}
|
||||
.st-focus{background:#EDE9FE;color:#6D28D9}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
MASTHEAD
|
||||
══════════════════════════════════════ -->
|
||||
<div class="mast">
|
||||
<div class="mast-top">
|
||||
<div>
|
||||
<h1>Tag Typeahead — Tree-Aware Results</h1>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
<span class="mast-badge mb-final">Design Spec</span>
|
||||
</div>
|
||||
<div class="decisions">
|
||||
<div class="dec">
|
||||
<div class="dec-label">API contract</div>
|
||||
<div class="dec-value">No change — GET /api/tags still returns List<Tag>. Backend enriches the flat list.</div>
|
||||
</div>
|
||||
<div class="dec">
|
||||
<div class="dec-label">Enrichment scope</div>
|
||||
<div class="dec-value">Root match → all descendants via recursive CTE. Child match → all ancestors via recursive CTE.</div>
|
||||
</div>
|
||||
<div class="dec">
|
||||
<div class="dec-label">Visual treatment</div>
|
||||
<div class="dec-value">Direct matches: full color + font-medium. Context nodes: text-ink-3 + chevron prefix for roots.</div>
|
||||
</div>
|
||||
<div class="dec">
|
||||
<div class="dec-label">Selectability</div>
|
||||
<div class="dec-value">Every item in the list is selectable — context ancestors are valid tags, not decorations.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spec-disclaimer">
|
||||
<strong>Scale notice:</strong> 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.
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 1 — BEFORE / AFTER
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h">
|
||||
<span class="sec-num">1</span>
|
||||
Before vs. After — the two gaps this closes
|
||||
</div>
|
||||
|
||||
<div class="sg sg-2" style="align-items:start">
|
||||
|
||||
<!-- BEFORE: parent matches, no children shown -->
|
||||
<div class="sb">
|
||||
<div class="sl"><span class="state-label st-before">Before</span> Parent matches — children missing</div>
|
||||
<div class="wf">
|
||||
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>familienarchiv / dokument / bearbeiten</span></div></div>
|
||||
<div class="wf-inner">
|
||||
<div class="N"><span class="logo">FAMILIENARCHIV</span><span class="nl on">Dokumente</span><span class="nl">Personen</span></div>
|
||||
<div class="MAIN">
|
||||
<div style="background:#fff;border:1.5px solid #e4e2d7;border-radius:3px;padding:11px 14px;box-shadow:0 1px 3px rgba(0,0,0,.06)">
|
||||
<div style="font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;margin-bottom:5px">Tags</div>
|
||||
<div class="TAG-INPUT-WRAP focused">
|
||||
<div class="TAG-INPUT" style="color:#012851">Brief</div>
|
||||
</div>
|
||||
<!-- Dropdown: only Briefe, children absent -->
|
||||
<div class="DD" style="max-width:100%">
|
||||
<div class="DD-ITEM match">
|
||||
<span class="dd-dot" style="background:#3060b0"></span>
|
||||
Briefe
|
||||
<span class="dd-badge">direct match</span>
|
||||
</div>
|
||||
<!-- children not present in current results -->
|
||||
</div>
|
||||
<div style="font-size:7px;color:#9ca3af;margin-top:8px;font-style:italic">
|
||||
Kinder von "Briefe" nicht sichtbar — obwohl relevant
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc">Current: only "Briefe" returned. Children like "Familienbriefe" and "Weihnachtsbriefe" are invisible unless the user already knows their exact names.</div>
|
||||
</div>
|
||||
|
||||
<!-- AFTER: parent matches, children shown -->
|
||||
<div class="sb">
|
||||
<div class="sl"><span class="state-label st-after">After</span> Parent matches — children shown below</div>
|
||||
<div class="wf">
|
||||
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>familienarchiv / dokument / bearbeiten</span></div></div>
|
||||
<div class="wf-inner">
|
||||
<div class="N"><span class="logo">FAMILIENARCHIV</span><span class="nl on">Dokumente</span><span class="nl">Personen</span></div>
|
||||
<div class="MAIN">
|
||||
<div style="background:#fff;border:1.5px solid #e4e2d7;border-radius:3px;padding:11px 14px;box-shadow:0 1px 3px rgba(0,0,0,.06)">
|
||||
<div style="font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;margin-bottom:5px">Tags</div>
|
||||
<div class="TAG-INPUT-WRAP focused">
|
||||
<div class="TAG-INPUT" style="color:#012851">Brief</div>
|
||||
</div>
|
||||
<!-- Dropdown: Briefe + children -->
|
||||
<div class="DD" style="max-width:100%">
|
||||
<div class="DD-ITEM match active">
|
||||
<span class="dd-dot" style="background:#3060b0"></span>
|
||||
Briefe
|
||||
<span class="dd-badge">↩ auswählen</span>
|
||||
</div>
|
||||
<div class="DD-ITEM context" style="padding-left:20px">
|
||||
<span class="dd-dot" style="background:#3060b0;opacity:.5"></span>
|
||||
Familienbriefe
|
||||
</div>
|
||||
<div class="DD-ITEM context" style="padding-left:20px">
|
||||
<span class="dd-dot" style="background:#3060b0;opacity:.5"></span>
|
||||
Weihnachtsbriefe
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:7px;color:#166534;margin-top:8px;font-style:italic">
|
||||
Kinder jetzt sichtbar und wählbar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc">After: "Briefe" shown prominently; its children appear indented below in muted style. User can select any of them directly.</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.sg-2 -->
|
||||
|
||||
<div style="height:28px"></div>
|
||||
|
||||
<div class="sg sg-2" style="align-items:start">
|
||||
|
||||
<!-- BEFORE: child matches, no ancestor path -->
|
||||
<div class="sb">
|
||||
<div class="sl"><span class="state-label st-before">Before</span> Child matches — ancestor path missing</div>
|
||||
<div class="wf">
|
||||
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>familienarchiv / dokument / bearbeiten</span></div></div>
|
||||
<div class="wf-inner">
|
||||
<div class="N"><span class="logo">FAMILIENARCHIV</span><span class="nl on">Dokumente</span><span class="nl">Personen</span></div>
|
||||
<div class="MAIN">
|
||||
<div style="background:#fff;border:1.5px solid #e4e2d7;border-radius:3px;padding:11px 14px;box-shadow:0 1px 3px rgba(0,0,0,.06)">
|
||||
<div style="font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;margin-bottom:5px">Tags</div>
|
||||
<div class="TAG-INPUT-WRAP focused">
|
||||
<div class="TAG-INPUT" style="color:#012851">Hochzeit</div>
|
||||
</div>
|
||||
<!-- Dropdown: only Hochzeit, no ancestor context -->
|
||||
<div class="DD" style="max-width:100%">
|
||||
<div class="DD-ITEM match active">
|
||||
<span class="dd-dot" style="background:#c17a00"></span>
|
||||
Hochzeit
|
||||
</div>
|
||||
<div class="DD-ITEM match">
|
||||
<span class="dd-dot" style="background:#c17a00"></span>
|
||||
Silberhochzeit
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:7px;color:#9ca3af;margin-top:8px;font-style:italic">
|
||||
Unklar, ob "Hochzeit" das richtige Tag ist
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc">Current: results appear without hierarchy context. User cannot tell which "Hochzeit" belongs where in the tree.</div>
|
||||
</div>
|
||||
|
||||
<!-- AFTER: child matches, ancestor shown above -->
|
||||
<div class="sb">
|
||||
<div class="sl"><span class="state-label st-after">After</span> Child matches — ancestor shown above for context</div>
|
||||
<div class="wf">
|
||||
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>familienarchiv / dokument / bearbeiten</span></div></div>
|
||||
<div class="wf-inner">
|
||||
<div class="N"><span class="logo">FAMILIENARCHIV</span><span class="nl on">Dokumente</span><span class="nl">Personen</span></div>
|
||||
<div class="MAIN">
|
||||
<div style="background:#fff;border:1.5px solid #e4e2d7;border-radius:3px;padding:11px 14px;box-shadow:0 1px 3px rgba(0,0,0,.06)">
|
||||
<div style="font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;margin-bottom:5px">Tags</div>
|
||||
<div class="TAG-INPUT-WRAP focused">
|
||||
<div class="TAG-INPUT" style="color:#012851">Hochzeit</div>
|
||||
</div>
|
||||
<!-- Dropdown: ancestor → children -->
|
||||
<div class="DD" style="max-width:100%">
|
||||
<div class="DD-ITEM context">
|
||||
<span class="dd-chevron">›</span>
|
||||
<span class="dd-dot" style="background:#c17a00;opacity:.4"></span>
|
||||
Ereignisse
|
||||
</div>
|
||||
<div class="DD-ITEM match active" style="padding-left:20px">
|
||||
<span class="dd-dot" style="background:#c17a00"></span>
|
||||
Hochzeit
|
||||
<span class="dd-badge">↩ auswählen</span>
|
||||
</div>
|
||||
<div class="DD-ITEM context" style="padding-left:20px">
|
||||
<span class="dd-dot" style="background:#c17a00;opacity:.5"></span>
|
||||
Silberhochzeit
|
||||
</div>
|
||||
<div class="DD-ITEM context" style="padding-left:20px">
|
||||
<span class="dd-dot" style="background:#c17a00;opacity:.5"></span>
|
||||
Goldene Hochzeit
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:7px;color:#166534;margin-top:8px;font-style:italic">
|
||||
"Ereignisse" als Kontext, alle Geschwister sichtbar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc">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.</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.sg-2 -->
|
||||
|
||||
</div><!-- /.sec -->
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 2 — DEEP TREE PATH (3 LEVELS)
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h">
|
||||
<span class="sec-num">2</span>
|
||||
Multi-level path — up to 3 levels deep
|
||||
</div>
|
||||
|
||||
<div class="sg sg-2" style="align-items:start">
|
||||
<div class="sb">
|
||||
<div class="sl">User types "gold" — match is 3 levels deep</div>
|
||||
<div class="wf">
|
||||
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>familienarchiv / dokument / bearbeiten</span></div></div>
|
||||
<div class="wf-inner">
|
||||
<div class="N"><span class="logo">FAMILIENARCHIV</span><span class="nl on">Dokumente</span><span class="nl">Personen</span></div>
|
||||
<div class="MAIN">
|
||||
<div style="background:#fff;border:1.5px solid #e4e2d7;border-radius:3px;padding:11px 14px;box-shadow:0 1px 3px rgba(0,0,0,.06)">
|
||||
<div style="font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;margin-bottom:5px">Tags</div>
|
||||
<div class="TAG-INPUT-WRAP focused">
|
||||
<div class="TAG-INPUT" style="color:#012851">gold</div>
|
||||
</div>
|
||||
<div class="DD" style="max-width:100%">
|
||||
<!-- depth 0: ancestor, no parentId in results -->
|
||||
<div class="DD-ITEM context">
|
||||
<span class="dd-chevron">›</span>
|
||||
<span class="dd-dot" style="background:#c17a00;opacity:.35"></span>
|
||||
Ereignisse
|
||||
</div>
|
||||
<!-- depth 1: intermediate ancestor, indented 16px -->
|
||||
<div class="DD-ITEM context" style="padding-left:20px">
|
||||
<span class="dd-dot" style="background:#c17a00;opacity:.45"></span>
|
||||
Hochzeit
|
||||
</div>
|
||||
<!-- depth 2: direct match, indented 32px -->
|
||||
<div class="DD-ITEM match active" style="padding-left:32px">
|
||||
<span class="dd-dot" style="background:#c17a00"></span>
|
||||
Goldene Hochzeit
|
||||
<span class="dd-badge">↩ auswählen</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc">Backend returns: Goldene Hochzeit (direct match) + Hochzeit + Ereignisse (ancestors). Frontend DFS produces depth 0 → 1 → 2 ordering. Indent = depth × 16px + 12px base.</div>
|
||||
</div>
|
||||
|
||||
<div class="sb">
|
||||
<div class="sl">Keyboard navigation across all depths</div>
|
||||
<div class="wf">
|
||||
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>familienarchiv / dokument / bearbeiten</span></div></div>
|
||||
<div class="wf-inner">
|
||||
<div class="N"><span class="logo">FAMILIENARCHIV</span><span class="nl on">Dokumente</span><span class="nl">Personen</span></div>
|
||||
<div class="MAIN">
|
||||
<div style="background:#fff;border:1.5px solid #e4e2d7;border-radius:3px;padding:11px 14px;box-shadow:0 1px 3px rgba(0,0,0,.06)">
|
||||
<div style="font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;margin-bottom:5px">Tags</div>
|
||||
<div class="TAG-INPUT-WRAP focused">
|
||||
<div class="TAG-INPUT" style="color:#012851">gold</div>
|
||||
</div>
|
||||
<div class="DD" style="max-width:100%">
|
||||
<!-- Active on Ereignisse (context item navigated to) -->
|
||||
<div class="DD-ITEM context active">
|
||||
<span class="dd-chevron">›</span>
|
||||
<span class="dd-dot" style="background:#c17a00;opacity:.35"></span>
|
||||
Ereignisse
|
||||
<span class="dd-badge" style="color:#6D28D9">↩ auswählen</span>
|
||||
</div>
|
||||
<div class="DD-ITEM context" style="padding-left:20px">
|
||||
<span class="dd-dot" style="background:#c17a00;opacity:.45"></span>
|
||||
Hochzeit
|
||||
</div>
|
||||
<div class="DD-ITEM match" style="padding-left:32px">
|
||||
<span class="dd-dot" style="background:#c17a00"></span>
|
||||
Goldene Hochzeit
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc">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.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="ann-info" style="max-width:680px">
|
||||
<strong>DFS ordering guarantee:</strong> 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.
|
||||
</div>
|
||||
|
||||
</div><!-- /.sec -->
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 3 — MIXED RESULTS (2 INDEPENDENT SUBTREES)
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h">
|
||||
<span class="sec-num">3</span>
|
||||
Mixed results — two independent subtrees in one dropdown
|
||||
</div>
|
||||
|
||||
<div class="sg sg-2" style="align-items:start">
|
||||
<div class="sb">
|
||||
<div class="sl">User types "post" — matches across two unrelated subtrees</div>
|
||||
<div class="wf">
|
||||
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>familienarchiv / dokument / bearbeiten</span></div></div>
|
||||
<div class="wf-inner">
|
||||
<div class="N"><span class="logo">FAMILIENARCHIV</span><span class="nl on">Dokumente</span><span class="nl">Personen</span></div>
|
||||
<div class="MAIN">
|
||||
<div style="background:#fff;border:1.5px solid #e4e2d7;border-radius:3px;padding:11px 14px;box-shadow:0 1px 3px rgba(0,0,0,.06)">
|
||||
<div style="font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;margin-bottom:5px">Tags</div>
|
||||
<div class="TAG-INPUT-WRAP focused">
|
||||
<div class="TAG-INPUT" style="color:#012851">post</div>
|
||||
</div>
|
||||
<div class="DD" style="max-width:100%">
|
||||
<!-- Subtree 1: Post (root match) + children -->
|
||||
<div class="DD-ITEM match">
|
||||
<span class="dd-dot" style="background:#5a8a6a"></span>
|
||||
Post
|
||||
</div>
|
||||
<div class="DD-ITEM context" style="padding-left:20px">
|
||||
<span class="dd-dot" style="background:#5a8a6a;opacity:.5"></span>
|
||||
Pakete
|
||||
</div>
|
||||
<div class="DD-ITEM context active" style="padding-left:20px">
|
||||
<span class="dd-dot" style="background:#5a8a6a;opacity:.5"></span>
|
||||
Postkarten
|
||||
<span class="dd-badge">↩ auswählen</span>
|
||||
</div>
|
||||
<!-- divider hint (visual only) -->
|
||||
<div style="height:1px;background:#e4e2d7;margin:2px 0"></div>
|
||||
<!-- Subtree 2: Postbank (child match) + ancestor -->
|
||||
<div class="DD-ITEM context">
|
||||
<span class="dd-chevron">›</span>
|
||||
<span class="dd-dot" style="background:#a0522d;opacity:.4"></span>
|
||||
Finanzen
|
||||
</div>
|
||||
<div class="DD-ITEM match" style="padding-left:20px">
|
||||
<span class="dd-dot" style="background:#a0522d"></span>
|
||||
Postbank
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc">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.</div>
|
||||
</div>
|
||||
|
||||
<div class="sb">
|
||||
<div class="sl">Already-selected tags are filtered from suggestions</div>
|
||||
<div class="wf">
|
||||
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>familienarchiv / dokument / bearbeiten</span></div></div>
|
||||
<div class="wf-inner">
|
||||
<div class="N"><span class="logo">FAMILIENARCHIV</span><span class="nl on">Dokumente</span><span class="nl">Personen</span></div>
|
||||
<div class="MAIN">
|
||||
<div style="background:#fff;border:1.5px solid #e4e2d7;border-radius:3px;padding:11px 14px;box-shadow:0 1px 3px rgba(0,0,0,.06)">
|
||||
<div style="font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#9ca3af;margin-bottom:5px">Tags</div>
|
||||
<!-- already selected: Post -->
|
||||
<div class="TAG-INPUT-WRAP focused">
|
||||
<div class="TAG-CHIP">
|
||||
<span class="TAG-DOT" style="background:#5a8a6a"></span>
|
||||
Post
|
||||
<span class="TAG-X">×</span>
|
||||
</div>
|
||||
<div class="TAG-INPUT" style="color:#012851">post</div>
|
||||
</div>
|
||||
<div class="DD" style="max-width:100%">
|
||||
<!-- Post is already selected → filtered out from results -->
|
||||
<!-- Only children and unrelated matches shown -->
|
||||
<div class="DD-ITEM context">
|
||||
<span class="dd-dot" style="background:#5a8a6a;opacity:.5"></span>
|
||||
Pakete
|
||||
<span class="dd-badge" style="color:#9ca3af;font-size:6px">Kind von Post</span>
|
||||
</div>
|
||||
<div class="DD-ITEM context active">
|
||||
<span class="dd-dot" style="background:#5a8a6a;opacity:.5"></span>
|
||||
Postkarten
|
||||
<span class="dd-badge">↩ auswählen</span>
|
||||
</div>
|
||||
<div style="height:1px;background:#e4e2d7;margin:2px 0"></div>
|
||||
<div class="DD-ITEM context">
|
||||
<span class="dd-chevron">›</span>
|
||||
<span class="dd-dot" style="background:#a0522d;opacity:.4"></span>
|
||||
Finanzen
|
||||
</div>
|
||||
<div class="DD-ITEM match" style="padding-left:20px">
|
||||
<span class="dd-dot" style="background:#a0522d"></span>
|
||||
Postbank
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc">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.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ann-block" style="max-width:680px">
|
||||
<strong>Context nodes of a filtered parent:</strong> 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.
|
||||
</div>
|
||||
|
||||
</div><!-- /.sec -->
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 4 — BACKEND IMPLEMENTATION REFERENCE
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h">
|
||||
<span class="sec-num">4</span>
|
||||
Backend implementation reference
|
||||
</div>
|
||||
|
||||
<div class="impl-ref" style="margin-top:0">
|
||||
<div class="impl-ref-hdr">Implementation Reference — §4 Backend
|
||||
<span>TagService.java · no new endpoint · no DTO change</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>What</th><th>How</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>File</td>
|
||||
<td><code>backend/src/main/java/…/service/TagService.java</code></td>
|
||||
<td>Modify existing <code>search(String query)</code> method only. No new endpoint, no new DTO, no API type regeneration needed.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Step 1 — name search</td>
|
||||
<td><code>List<Tag> matched = tagRepository.findByNameContainingIgnoreCase(query);</code></td>
|
||||
<td>Existing query, unchanged. Returns early if empty.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Step 2 — collect extra IDs</td>
|
||||
<td><pre>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);</pre></td>
|
||||
<td><code>findDescendantIds</code> and <code>findAncestorIds</code> are recursive CTEs already in <code>TagRepository</code>, depth-guarded at 50 levels. Both return <code>List<UUID></code> including the seed ID — removing matched IDs deduplicates cleanly.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Step 3 — batch fetch & merge</td>
|
||||
<td><pre>List<Tag> all = new ArrayList<>(matched);
|
||||
if (!extraIds.isEmpty()) {
|
||||
all.addAll(tagRepository.findAllById(extraIds));
|
||||
}
|
||||
resolveEffectiveColors(all);
|
||||
return all;</pre></td>
|
||||
<td><code>tagRepository.findAllById()</code> is inherited from <code>JpaRepository</code> — one IN-clause query. <code>resolveEffectiveColors()</code> already exists in TagService; it populates inherited color from parent for child tags.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Query count</td>
|
||||
<td>1 (name search) + N (CTE per matched tag) + 1 (batch fetch)</td>
|
||||
<td>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.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Return type</td>
|
||||
<td><code>List<Tag></code> — unchanged</td>
|
||||
<td>No API contract change. Frontend Tag type (<code>{ id, name, color?, parentId? }</code>) already carries <code>parentId</code>. No <code>generate:api</code> run needed.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Unit tests to add</td>
|
||||
<td><code>TagServiceTest.java</code></td>
|
||||
<td>
|
||||
1. <code>search_includes_children_when_root_matches()</code> — mock root match, mock <code>findDescendantIds</code>, assert children in result.<br>
|
||||
2. <code>search_includes_ancestors_when_child_matches()</code> — mock child match with parentId, mock <code>findAncestorIds</code>, assert parent in result.<br>
|
||||
3. <code>search_deduplicates_when_ancestor_also_matches()</code> — parent matches AND child matches; ancestor not duplicated in result.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div><!-- /.sec -->
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
SECTION 5 — FRONTEND IMPLEMENTATION REFERENCE
|
||||
══════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h">
|
||||
<span class="sec-num">5</span>
|
||||
Frontend implementation reference
|
||||
</div>
|
||||
|
||||
<div class="impl-ref" style="margin-top:0">
|
||||
<div class="impl-ref-hdr">Implementation Reference — §5 TagInput.svelte
|
||||
<span>frontend/src/lib/components/TagInput.svelte</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>What</th><th>How</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>New type alias</td>
|
||||
<td><pre>type SuggestionEntry = {
|
||||
tag: Tag;
|
||||
depth: number;
|
||||
isDirectMatch: boolean;
|
||||
};</pre></td>
|
||||
<td>Replace the current <code>Tag[]</code> return type of <code>orderedSuggestions</code> with <code>SuggestionEntry[]</code>. The <code>Tag</code> type is already imported from the local definition at the top of the file.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Direct match set</td>
|
||||
<td><pre>const directMatchIds = $derived(
|
||||
new Set(
|
||||
suggestions
|
||||
.filter(s => s.id &&
|
||||
s.name.toLowerCase()
|
||||
.includes(inputVal.toLowerCase()))
|
||||
.map(s => s.id!)
|
||||
)
|
||||
);</pre></td>
|
||||
<td>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 <code>isDirectMatch</code> will be false for them.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Replace orderedSuggestions</td>
|
||||
<td><pre>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;
|
||||
});</pre></td>
|
||||
<td>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.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Update handleKeydown</td>
|
||||
<td><pre>// Before: addTag(orderedSuggestions[activeIndex])
|
||||
// After:
|
||||
addTag(orderedSuggestions[activeIndex].tag)</pre></td>
|
||||
<td>The <code>activeIndex</code> logic itself is unchanged. Only the access pattern changes: <code>.tag</code> to unwrap the <code>SuggestionEntry</code>.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Dropdown template item</td>
|
||||
<td><pre>{#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}</pre></td>
|
||||
<td>
|
||||
Indent formula: <code>depth × 16 + 12</code> px (inline style — Tailwind dynamic classes not viable for variable values).<br><br>
|
||||
Context nodes at depth 0 (ancestors without a parent in results) get a <code>›</code> prefix to signal "this is context, not a primary result".<br><br>
|
||||
Color dot opacity: 100% for direct matches, 50% for context nodes.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Remove pl-6 class</td>
|
||||
<td>Delete the old conditional <code>{suggestion.parentId && suggestionsById.has(suggestion.parentId) ? 'pl-6' : ''}</code></td>
|
||||
<td>Indentation is now handled entirely by the <code>style="padding-left: …"</code> inline style based on computed depth. The old <code>suggestionsById</code> derived map can also be removed as it's no longer needed.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Depth indentation</td>
|
||||
<td><span class="ir-px">depth 0 → 12px · depth 1 → 28px · depth 2 → 44px</span></td>
|
||||
<td>Formula: <code>depth * 16 + 12</code>. Maximum realistic depth in this app is 3–4 levels. No truncation needed.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Context node style</td>
|
||||
<td><code>text-ink-3</code> (<span class="ir-px">#6b7280</span>) · no font-weight override</td>
|
||||
<td>Context nodes (not a direct match) use the muted gray. When hovered or keyboard-active, they get <code>bg-muted font-bold text-ink</code> — same as any other item. They are fully clickable.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>i18n keys</td>
|
||||
<td>None new required.</td>
|
||||
<td>The feature is purely visual. All existing <code>comp_taginput_*</code> keys remain applicable.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Frontend tests to update</td>
|
||||
<td><code>TagInput.svelte.spec.ts</code></td>
|
||||
<td>
|
||||
1. <em>Update</em> existing "shows child after parent" test — it now expects <code>SuggestionEntry</code> depth/match info.<br>
|
||||
2. <em>Add:</em> ancestor shown above direct match with <code>text-ink-3</code>.<br>
|
||||
3. <em>Add:</em> 3-level path renders in correct DFS order.<br>
|
||||
4. <em>Add:</em> keyboard Enter on context node adds it as a tag.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div><!-- /.sec -->
|
||||
|
||||
</div><!-- /.doc -->
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user