feat(#248): replace flat TagsListPanel with collapsible ARIA tree (TagTreeNode)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 22:57:45 +02:00
parent 9b5af67780
commit 97fbf1e4ca
4 changed files with 237 additions and 116 deletions

View File

@@ -8,7 +8,7 @@ const isAtListRoot = $derived(page.url.pathname === '/admin/tags');
</script>
<div class="{isAtListRoot ? 'flex' : 'hidden'} flex-shrink-0 md:flex">
<TagsListPanel tags={data.tags} />
<TagsListPanel tree={data.tree} />
</div>
<div class="{isAtListRoot ? 'hidden' : 'flex'} min-w-0 flex-1 flex-col overflow-hidden md:flex">

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import { page } from '$app/state';
import { SvelteMap } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import TagTreeNode from './TagTreeNode.svelte';
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
let {
node,
depth,
collapseMap
}: {
node: TagTreeNodeDTO;
depth: number;
collapseMap: SvelteMap<string, boolean>;
} = $props();
const hasChildren = $derived((node.children?.length ?? 0) > 0);
const isCollapsed = $derived(collapseMap.get(node.id!) ?? false);
const isActive = $derived(page.url.pathname.startsWith('/admin/tags/' + node.id));
function toggleCollapse() {
collapseMap.set(node.id!, !isCollapsed);
}
</script>
<li role="treeitem" aria-selected={isActive} aria-expanded={hasChildren ? !isCollapsed : undefined}>
<div class="flex items-center" style="padding-left: {depth * 12}px">
{#if hasChildren}
<button
onclick={toggleCollapse}
aria-label={isCollapsed ? m.admin_tag_expand_node() : m.admin_tag_collapse_node()}
class="flex min-h-[44px] min-w-[44px] items-center justify-center text-ink-3 transition-colors hover:text-ink"
>
<svg
class="h-3 w-3 transition-transform {isCollapsed ? '' : 'rotate-90'}"
viewBox="0 0 12 12"
fill="currentColor"
aria-hidden="true"
>
<path d="M4 2l4 4-4 4V2z" />
</svg>
</button>
{:else}
<button
class="invisible flex min-h-[44px] min-w-[44px] items-center justify-center"
tabindex="-1"
aria-hidden="true"
></button>
{/if}
{#if depth === 0 && node.color}
<span
data-testid="tag-list-color-dot"
data-color={node.color}
style="background-color: var(--c-tag-{node.color})"
class="mr-1.5 inline-block h-2 w-2 flex-shrink-0 rounded-full"
></span>
{/if}
<a
href="/admin/tags/{node.id}"
aria-current={isActive ? 'page' : undefined}
class="flex-1 truncate py-1 text-sm font-bold text-ink transition-colors hover:text-primary {isActive
? 'text-primary'
: ''}"
>
{node.name}
{#if (node.documentCount ?? 0) > 0}
<span class="ml-1 text-xs font-normal text-ink-3">({node.documentCount})</span>
{/if}
</a>
</div>
{#if hasChildren && !isCollapsed}
<ul role="group">
{#each node.children! as child (child.id)}
<TagTreeNode node={child} depth={depth + 1} collapseMap={collapseMap} />
{/each}
</ul>
{/if}
</li>

View File

@@ -1,48 +1,42 @@
<script lang="ts">
import { page } from '$app/state';
import { SvelteMap } from 'svelte/reactivity';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import TagTreeNode from './TagTreeNode.svelte';
type Tag = {
id: string;
name: string;
parentId?: string;
color?: string;
};
type TagWithDepth = Tag & { depth: number };
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
let {
tags,
tree,
autocollapse = false
}: {
tags: Tag[];
tree: TagTreeNodeDTO[];
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);
function loadCollapseMap(): SvelteMap<string, boolean> {
if (typeof localStorage !== 'undefined') {
try {
const stored = localStorage.getItem('admin_tags_tree_state');
const parsed: Record<string, boolean> = stored ? JSON.parse(stored) : {};
return new SvelteMap(Object.entries(parsed));
} catch {
// ignore parse errors
}
}
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);
return new SvelteMap();
}
const collapseMap = loadCollapseMap();
$effect(() => {
if (typeof localStorage !== 'undefined') {
const obj: Record<string, boolean> = {};
for (const [k, v] of collapseMap) {
obj[k] = v;
}
localStorage.setItem('admin_tags_tree_state', JSON.stringify(obj));
}
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(
@@ -74,13 +68,11 @@ $effect(() => {
</span>
</button>
{:else}
<div
class="flex w-[200px] flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface"
>
<div class="flex w-60 flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface">
<!-- Panel header -->
<div class="flex items-center justify-between border-b border-line px-3 py-2">
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_tags_list_title()}
{m.admin_tag_tree_label()}
</span>
<button
onclick={() => (manualCollapse = true)}
@@ -91,35 +83,18 @@ $effect(() => {
</button>
</div>
<!-- Scrollable tag list -->
<!-- Scrollable tag tree -->
<div class="flex-1 overflow-y-auto">
{#if tags.length === 0}
{#if tree.length === 0}
<p class="px-4 py-6 text-center text-xs text-ink-3">
{m.admin_tags_empty()}
</p>
{:else}
{#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'} {tag.depth > 0 ? 'pl-5' : ''}"
>
<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}
<ul role="tree" aria-label={m.admin_tag_tree_label()}>
{#each tree as node (node.id)}
<TagTreeNode node={node} depth={0} collapseMap={collapseMap} />
{/each}
</ul>
{/if}
</div>
</div>

View File

@@ -9,44 +9,97 @@ vi.mock('$app/state', () => ({
afterEach(cleanup);
const tags = [
{ id: 't1', name: 'Familie' },
{ id: 't2', name: 'Urlaub' },
{ id: 't3', name: 'Schule' }
const tree = [
{
id: 't1',
name: 'Familie',
color: undefined,
documentCount: 3,
parentId: undefined,
children: [
{ id: 't2', name: 'Eltern', color: undefined, documentCount: 2, parentId: 't1', children: [] }
]
},
{
id: 't3',
name: 'Urlaub',
color: 'teal',
documentCount: 0,
parentId: undefined,
children: []
}
];
describe('TagsListPanel — header', () => {
it('renders the panel title', async () => {
render(TagsListPanel, { tags });
await expect.element(page.getByText(/Alle Schlagworte/i)).toBeInTheDocument();
render(TagsListPanel, { tree });
await expect.element(page.getByText(/Schlagwörter/i)).toBeInTheDocument();
});
});
describe('TagsListPanel — ARIA tree', () => {
it('renders a role="tree" container', async () => {
const { container } = render(TagsListPanel, { tree });
expect(container.querySelector('[role="tree"]')).toBeTruthy();
});
it('renders role="treeitem" on each item', async () => {
const { container } = render(TagsListPanel, { tree });
const items = container.querySelectorAll('[role="treeitem"]');
// Both parent and child treeitem
expect(items.length).toBeGreaterThanOrEqual(2);
});
it('sets aria-expanded on nodes with children', async () => {
const { container } = render(TagsListPanel, { tree });
const familieItem = container.querySelector<HTMLElement>('[role="treeitem"]');
expect(familieItem?.hasAttribute('aria-expanded')).toBe(true);
});
it('does NOT set aria-expanded on leaf nodes', async () => {
const { container } = render(TagsListPanel, { tree });
const items = container.querySelectorAll<HTMLElement>('[role="treeitem"]');
const urlaubItem = Array.from(items).find((el) => el.textContent?.includes('Urlaub'));
expect(urlaubItem?.hasAttribute('aria-expanded')).toBe(false);
});
});
describe('TagsListPanel — tag items', () => {
it('renders each tag name', async () => {
render(TagsListPanel, { tags });
it('renders each root tag name as a link', async () => {
render(TagsListPanel, { tree });
await expect.element(page.getByRole('link', { name: /familie/i })).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: /urlaub/i })).toBeInTheDocument();
});
it('each tag links to /admin/tags/[id]', async () => {
const { container } = render(TagsListPanel, { tags });
const links = container.querySelectorAll<HTMLAnchorElement>('a[href^="/admin/tags/t"]');
expect(links.length).toBe(3);
expect(links[0].getAttribute('href')).toBe('/admin/tags/t1');
const { container } = render(TagsListPanel, { tree });
const link = container.querySelector<HTMLAnchorElement>('a[href="/admin/tags/t1"]');
expect(link).toBeTruthy();
});
it('renders document count badge when documentCount > 0', async () => {
const { container } = render(TagsListPanel, { tree });
// Familie has count 3 — should show "(3)"
expect(container.textContent).toContain('(3)');
});
it('does not render count badge when documentCount is 0', async () => {
const { container } = render(TagsListPanel, { tree });
// Urlaub has count 0 — should NOT show "(0)"
expect(container.textContent).not.toContain('(0)');
});
});
describe('TagsListPanel — active state', () => {
it('marks the active tag link with aria-current=page', async () => {
render(TagsListPanel, { tags });
render(TagsListPanel, { tree });
await expect
.element(page.getByRole('link', { name: /familie/i }))
.toHaveAttribute('aria-current', 'page');
});
it('does not mark inactive tag links with aria-current', async () => {
render(TagsListPanel, { tags });
render(TagsListPanel, { tree });
await expect
.element(page.getByRole('link', { name: /urlaub/i }))
.not.toHaveAttribute('aria-current');
@@ -54,69 +107,52 @@ describe('TagsListPanel — active state', () => {
});
describe('TagsListPanel — empty state', () => {
it('shows empty state when tags array is empty', async () => {
render(TagsListPanel, { tags: [] });
it('shows empty state when tree is empty', async () => {
render(TagsListPanel, { tree: [] });
await expect.element(page.getByText(/keine schlagworte/i)).toBeInTheDocument();
});
});
// ─── 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' }]
});
describe('TagsListPanel — color dot', () => {
it('renders color dot on root tags that have a color', async () => {
render(TagsListPanel, { tree });
const dot = page.getByTestId('tag-list-color-dot');
await expect.element(dot).toBeInTheDocument();
await expect.element(dot).toHaveAttribute('data-color', 'sage');
await expect.element(dot).toHaveAttribute('data-color', 'teal');
});
it('does not render color dot on tags without a color', async () => {
render(TagsListPanel, { tags: [{ id: 't1', name: 'Familie' }] });
render(TagsListPanel, {
tree: [
{
id: 't1',
name: 'Familie',
documentCount: 0,
parentId: undefined,
children: [],
color: undefined
}
]
});
await expect.element(page.getByTestId('tag-list-color-dot')).not.toBeInTheDocument();
});
});
// ─── Collapse toggle ──────────────────────────────────────────────────────────
describe('TagsListPanel — collapse toggle', () => {
beforeEach(() => localStorage.removeItem('admin_tags_list_collapsed'));
beforeEach(() => {
localStorage.removeItem('admin_tags_list_collapsed');
localStorage.removeItem('admin_tags_tree_state');
});
it('renders a collapse button with aria-label', async () => {
render(TagsListPanel, { tags });
render(TagsListPanel, { tree });
await expect
.element(page.getByRole('button', { name: /Liste einklappen/i }))
.toBeInTheDocument();
});
it('clicking collapse shows the expand handle', async () => {
render(TagsListPanel, { tags });
render(TagsListPanel, { tree });
await page.getByRole('button', { name: /Liste einklappen/i }).click();
await expect
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
@@ -124,14 +160,14 @@ describe('TagsListPanel — collapse toggle', () => {
});
it('autocollapse prop starts the panel in collapsed state', async () => {
render(TagsListPanel, { tags, autocollapse: true });
render(TagsListPanel, { tree, autocollapse: true });
await expect
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
.toBeInTheDocument();
});
it('persists collapse state using the tags-specific localStorage key', async () => {
render(TagsListPanel, { tags });
render(TagsListPanel, { tree });
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
await vi.waitFor(() =>
@@ -140,3 +176,29 @@ describe('TagsListPanel — collapse toggle', () => {
setSpy.mockRestore();
});
});
describe('TagsListPanel — chevron collapse', () => {
beforeEach(() => {
localStorage.removeItem('admin_tags_tree_state');
localStorage.removeItem('admin_tags_list_collapsed');
});
it('child items are visible by default', async () => {
render(TagsListPanel, { tree });
await expect.element(page.getByRole('link', { name: /eltern/i })).toBeInTheDocument();
});
it('clicking the chevron collapses children', async () => {
const { container } = render(TagsListPanel, { tree });
// Find the chevron button inside the Familie treeitem
const familieItem = Array.from(container.querySelectorAll('[role="treeitem"]')).find((el) =>
el.textContent?.includes('Familie')
);
const chevron = familieItem?.querySelector<HTMLButtonElement>('button[aria-label]');
chevron?.click();
await vi.waitFor(() => {
const eltern = container.querySelector('a[href="/admin/tags/t2"]');
expect(eltern).toBeFalsy();
});
});
});