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:
@@ -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">
|
||||
|
||||
84
frontend/src/routes/admin/tags/TagTreeNode.svelte
Normal file
84
frontend/src/routes/admin/tags/TagTreeNode.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user