feat(#221): render tag list hierarchically with indentation and color dots

TagsListPanel now accepts optional parentId/color on each Tag. A
$derived.by walk produces an ordered flat list with depth annotations.
Child tags are indented with pl-5; root-level tags with a color get
a colored dot before their name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 16:46:55 +02:00
parent d900480920
commit 7f53651f13
2 changed files with 86 additions and 3 deletions

View File

@@ -1,12 +1,17 @@
<script lang="ts">
import { page } from '$app/state';
import { SvelteMap } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
type Tag = {
id: string;
name: string;
parentId?: string;
color?: string;
};
type TagWithDepth = Tag & { depth: number };
let {
tags,
autocollapse = false
@@ -15,6 +20,31 @@ let {
autocollapse?: boolean;
} = $props();
const orderedTags = $derived.by((): TagWithDepth[] => {
const roots = tags.filter((t) => !t.parentId);
const byParent = new SvelteMap<string, Tag[]>();
for (const t of tags) {
if (t.parentId) {
const arr = byParent.get(t.parentId) ?? [];
arr.push(t);
byParent.set(t.parentId, arr);
}
}
const result: TagWithDepth[] = [];
function walk(node: Tag, depth: number) {
result.push({ ...node, depth });
for (const child of byParent.get(node.id) ?? []) {
walk(child, depth + 1);
}
}
for (const root of roots) walk(root, 0);
// Append orphan children (parent not in list)
for (const t of tags) {
if (!result.find((r) => r.id === t.id)) result.push({ ...t, depth: 0 });
}
return result;
});
let manualCollapse = $state(
typeof localStorage !== 'undefined' &&
localStorage.getItem('admin_tags_list_collapsed') === 'true'
@@ -68,16 +98,26 @@ $effect(() => {
{m.admin_tags_empty()}
</p>
{:else}
{#each tags as tag (tag.id)}
{#each orderedTags as tag (tag.id)}
{@const isActive = page.url.pathname.startsWith('/admin/tags/' + tag.id)}
<a
href="/admin/tags/{tag.id}"
aria-current={isActive ? 'page' : undefined}
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
? 'border-primary bg-primary/10 dark:bg-primary/15'
: 'border-transparent hover:bg-muted'}"
: 'border-transparent hover:bg-muted'} {tag.depth > 0 ? 'pl-5' : ''}"
>
<div class="text-sm font-bold text-ink">{tag.name}</div>
<div class="flex items-center text-sm font-bold text-ink">
{#if tag.color}
<span
data-testid="tag-list-color-dot"
data-color={tag.color}
style="background-color: var(--c-tag-{tag.color})"
class="mr-1.5 inline-block h-2 w-2 flex-shrink-0 rounded-full"
></span>
{/if}
{tag.name}
</div>
</a>
{/each}
{/if}

View File

@@ -60,6 +60,49 @@ describe('TagsListPanel — empty state', () => {
});
});
// ─── Hierarchy rendering ──────────────────────────────────────────────────────
describe('TagsListPanel — hierarchy', () => {
it('renders child tags indented under their parent', async () => {
render(TagsListPanel, {
tags: [
{ id: 't1', name: 'Immobilie' },
{ id: 't2', name: 'Haus', parentId: 't1' }
]
});
const childEl = document.querySelector<HTMLAnchorElement>('a[href="/admin/tags/t2"]');
expect(childEl?.className).toContain('pl-');
});
it('renders child after parent in the list', async () => {
render(TagsListPanel, {
tags: [
{ id: 't1', name: 'Immobilie' },
{ id: 't2', name: 'Haus', parentId: 't1' }
]
});
const links = document.querySelectorAll<HTMLAnchorElement>('a[href^="/admin/tags/t"]');
const hrefs = Array.from(links).map((l) => l.getAttribute('href'));
const parentIdx = hrefs.indexOf('/admin/tags/t1');
const childIdx = hrefs.indexOf('/admin/tags/t2');
expect(childIdx).toBeGreaterThan(parentIdx);
});
it('renders color dot on tags that have a color', async () => {
render(TagsListPanel, {
tags: [{ id: 't1', name: 'Immobilie', color: 'sage' }]
});
const dot = page.getByTestId('tag-list-color-dot');
await expect.element(dot).toBeInTheDocument();
await expect.element(dot).toHaveAttribute('data-color', 'sage');
});
it('does not render color dot on tags without a color', async () => {
render(TagsListPanel, { tags: [{ id: 't1', name: 'Familie' }] });
await expect.element(page.getByTestId('tag-list-color-dot')).not.toBeInTheDocument();
});
});
// ─── Collapse toggle ──────────────────────────────────────────────────────────
describe('TagsListPanel — collapse toggle', () => {