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>
|
</script>
|
||||||
|
|
||||||
<div class="{isAtListRoot ? 'flex' : 'hidden'} flex-shrink-0 md:flex">
|
<div class="{isAtListRoot ? 'flex' : 'hidden'} flex-shrink-0 md:flex">
|
||||||
<TagsListPanel tags={data.tags} />
|
<TagsListPanel tree={data.tree} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="{isAtListRoot ? 'hidden' : 'flex'} min-w-0 flex-1 flex-col overflow-hidden md:flex">
|
<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">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
|
||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
import TagTreeNode from './TagTreeNode.svelte';
|
||||||
|
|
||||||
type Tag = {
|
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
parentId?: string;
|
|
||||||
color?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TagWithDepth = Tag & { depth: number };
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
tags,
|
tree,
|
||||||
autocollapse = false
|
autocollapse = false
|
||||||
}: {
|
}: {
|
||||||
tags: Tag[];
|
tree: TagTreeNodeDTO[];
|
||||||
autocollapse?: boolean;
|
autocollapse?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const orderedTags = $derived.by((): TagWithDepth[] => {
|
function loadCollapseMap(): SvelteMap<string, boolean> {
|
||||||
const roots = tags.filter((t) => !t.parentId);
|
if (typeof localStorage !== 'undefined') {
|
||||||
const byParent = new SvelteMap<string, Tag[]>();
|
try {
|
||||||
for (const t of tags) {
|
const stored = localStorage.getItem('admin_tags_tree_state');
|
||||||
if (t.parentId) {
|
const parsed: Record<string, boolean> = stored ? JSON.parse(stored) : {};
|
||||||
const arr = byParent.get(t.parentId) ?? [];
|
return new SvelteMap(Object.entries(parsed));
|
||||||
arr.push(t);
|
} catch {
|
||||||
byParent.set(t.parentId, arr);
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result: TagWithDepth[] = [];
|
return new SvelteMap();
|
||||||
function walk(node: Tag, depth: number) {
|
}
|
||||||
result.push({ ...node, depth });
|
|
||||||
for (const child of byParent.get(node.id) ?? []) {
|
const collapseMap = loadCollapseMap();
|
||||||
walk(child, depth + 1);
|
|
||||||
|
$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(
|
let manualCollapse = $state(
|
||||||
@@ -74,13 +68,11 @@ $effect(() => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div class="flex w-60 flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface">
|
||||||
class="flex w-[200px] flex-shrink-0 flex-col overflow-hidden border-r border-line bg-surface"
|
|
||||||
>
|
|
||||||
<!-- Panel header -->
|
<!-- Panel header -->
|
||||||
<div class="flex items-center justify-between border-b border-line px-3 py-2">
|
<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">
|
<span class="text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.admin_tags_list_title()}
|
{m.admin_tag_tree_label()}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onclick={() => (manualCollapse = true)}
|
onclick={() => (manualCollapse = true)}
|
||||||
@@ -91,35 +83,18 @@ $effect(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable tag list -->
|
<!-- Scrollable tag tree -->
|
||||||
<div class="flex-1 overflow-y-auto">
|
<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">
|
<p class="px-4 py-6 text-center text-xs text-ink-3">
|
||||||
{m.admin_tags_empty()}
|
{m.admin_tags_empty()}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each orderedTags as tag (tag.id)}
|
<ul role="tree" aria-label={m.admin_tag_tree_label()}>
|
||||||
{@const isActive = page.url.pathname.startsWith('/admin/tags/' + tag.id)}
|
{#each tree as node (node.id)}
|
||||||
<a
|
<TagTreeNode node={node} depth={0} collapseMap={collapseMap} />
|
||||||
href="/admin/tags/{tag.id}"
|
{/each}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
</ul>
|
||||||
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}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,44 +9,97 @@ vi.mock('$app/state', () => ({
|
|||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
const tags = [
|
const tree = [
|
||||||
{ id: 't1', name: 'Familie' },
|
{
|
||||||
{ id: 't2', name: 'Urlaub' },
|
id: 't1',
|
||||||
{ id: 't3', name: 'Schule' }
|
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', () => {
|
describe('TagsListPanel — header', () => {
|
||||||
it('renders the panel title', async () => {
|
it('renders the panel title', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await expect.element(page.getByText(/Alle Schlagworte/i)).toBeInTheDocument();
|
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', () => {
|
describe('TagsListPanel — tag items', () => {
|
||||||
it('renders each tag name', async () => {
|
it('renders each root tag name as a link', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await expect.element(page.getByRole('link', { name: /familie/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /familie/i })).toBeInTheDocument();
|
||||||
await expect.element(page.getByRole('link', { name: /urlaub/i })).toBeInTheDocument();
|
await expect.element(page.getByRole('link', { name: /urlaub/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('each tag links to /admin/tags/[id]', async () => {
|
it('each tag links to /admin/tags/[id]', async () => {
|
||||||
const { container } = render(TagsListPanel, { tags });
|
const { container } = render(TagsListPanel, { tree });
|
||||||
const links = container.querySelectorAll<HTMLAnchorElement>('a[href^="/admin/tags/t"]');
|
const link = container.querySelector<HTMLAnchorElement>('a[href="/admin/tags/t1"]');
|
||||||
expect(links.length).toBe(3);
|
expect(link).toBeTruthy();
|
||||||
expect(links[0].getAttribute('href')).toBe('/admin/tags/t1');
|
});
|
||||||
|
|
||||||
|
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', () => {
|
describe('TagsListPanel — active state', () => {
|
||||||
it('marks the active tag link with aria-current=page', async () => {
|
it('marks the active tag link with aria-current=page', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('link', { name: /familie/i }))
|
.element(page.getByRole('link', { name: /familie/i }))
|
||||||
.toHaveAttribute('aria-current', 'page');
|
.toHaveAttribute('aria-current', 'page');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not mark inactive tag links with aria-current', async () => {
|
it('does not mark inactive tag links with aria-current', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('link', { name: /urlaub/i }))
|
.element(page.getByRole('link', { name: /urlaub/i }))
|
||||||
.not.toHaveAttribute('aria-current');
|
.not.toHaveAttribute('aria-current');
|
||||||
@@ -54,69 +107,52 @@ describe('TagsListPanel — active state', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('TagsListPanel — empty state', () => {
|
describe('TagsListPanel — empty state', () => {
|
||||||
it('shows empty state when tags array is empty', async () => {
|
it('shows empty state when tree is empty', async () => {
|
||||||
render(TagsListPanel, { tags: [] });
|
render(TagsListPanel, { tree: [] });
|
||||||
await expect.element(page.getByText(/keine schlagworte/i)).toBeInTheDocument();
|
await expect.element(page.getByText(/keine schlagworte/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Hierarchy rendering ──────────────────────────────────────────────────────
|
describe('TagsListPanel — color dot', () => {
|
||||||
|
it('renders color dot on root tags that have a color', async () => {
|
||||||
describe('TagsListPanel — hierarchy', () => {
|
render(TagsListPanel, { tree });
|
||||||
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');
|
const dot = page.getByTestId('tag-list-color-dot');
|
||||||
await expect.element(dot).toBeInTheDocument();
|
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 () => {
|
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();
|
await expect.element(page.getByTestId('tag-list-color-dot')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Collapse toggle ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('TagsListPanel — 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 () => {
|
it('renders a collapse button with aria-label', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: /Liste einklappen/i }))
|
.element(page.getByRole('button', { name: /Liste einklappen/i }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clicking collapse shows the expand handle', async () => {
|
it('clicking collapse shows the expand handle', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
await page.getByRole('button', { name: /Liste einklappen/i }).click();
|
await page.getByRole('button', { name: /Liste einklappen/i }).click();
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
|
.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 () => {
|
it('autocollapse prop starts the panel in collapsed state', async () => {
|
||||||
render(TagsListPanel, { tags, autocollapse: true });
|
render(TagsListPanel, { tree, autocollapse: true });
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
|
.element(page.getByRole('button', { name: /Liste ausklappen/i }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists collapse state using the tags-specific localStorage key', async () => {
|
it('persists collapse state using the tags-specific localStorage key', async () => {
|
||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tree });
|
||||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
@@ -140,3 +176,29 @@ describe('TagsListPanel — collapse toggle', () => {
|
|||||||
setSpy.mockRestore();
|
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