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">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
type Tag = {
|
type Tag = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
parentId?: string;
|
||||||
|
color?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TagWithDepth = Tag & { depth: number };
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tags,
|
tags,
|
||||||
autocollapse = false
|
autocollapse = false
|
||||||
@@ -15,6 +20,31 @@ let {
|
|||||||
autocollapse?: boolean;
|
autocollapse?: boolean;
|
||||||
} = $props();
|
} = $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(
|
let manualCollapse = $state(
|
||||||
typeof localStorage !== 'undefined' &&
|
typeof localStorage !== 'undefined' &&
|
||||||
localStorage.getItem('admin_tags_list_collapsed') === 'true'
|
localStorage.getItem('admin_tags_list_collapsed') === 'true'
|
||||||
@@ -68,16 +98,26 @@ $effect(() => {
|
|||||||
{m.admin_tags_empty()}
|
{m.admin_tags_empty()}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each tags as tag (tag.id)}
|
{#each orderedTags as tag (tag.id)}
|
||||||
{@const isActive = page.url.pathname.startsWith('/admin/tags/' + tag.id)}
|
{@const isActive = page.url.pathname.startsWith('/admin/tags/' + tag.id)}
|
||||||
<a
|
<a
|
||||||
href="/admin/tags/{tag.id}"
|
href="/admin/tags/{tag.id}"
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
|
class="block border-l-2 px-3 py-2.5 transition-colors {isActive
|
||||||
? 'border-primary bg-primary/10 dark:bg-primary/15'
|
? '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>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/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 ──────────────────────────────────────────────────────────
|
// ─── Collapse toggle ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('TagsListPanel — collapse toggle', () => {
|
describe('TagsListPanel — collapse toggle', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user