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:
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user