diff --git a/frontend/src/routes/admin/EntityNav.svelte b/frontend/src/routes/admin/EntityNav.svelte
index bffc5b76..a8db2d2c 100644
--- a/frontend/src/routes/admin/EntityNav.svelte
+++ b/frontend/src/routes/admin/EntityNav.svelte
@@ -3,6 +3,7 @@ import { tick } from 'svelte';
import { fly } from 'svelte/transition';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages.js';
+import EntityNavSection from './EntityNavSection.svelte';
let {
userCount,
@@ -51,6 +52,76 @@ function handleKeydown(event: KeyboardEvent) {
}
+{#snippet usersIcon()}
+
+{/snippet}
+
+{#snippet groupsIcon()}
+
+{/snippet}
+
+{#snippet tagsIcon()}
+
+{/snippet}
+
+{#snippet systemIcon()}
+
+{/snippet}
+
-
-
-
-
-
- {userCount}
-
-
-
+ label={m.admin_tab_users()}
+ isActive={isActive('users')}
+ count={userCount}
+ onTabletTrigger={openFlyout}
+ icon={usersIcon}
+ />
{/if}
{#if canManagePermissions}
-
-
-
-
-
-
- {groupCount}
-
-
-
+ label={m.admin_tab_groups()}
+ isActive={isActive('groups')}
+ count={groupCount}
+ onTabletTrigger={openFlyout}
+ icon={groupsIcon}
+ />
{/if}
{#if canManageTags}
-
-
-
-
-
-
- {tagCount}
-
-
-
+ label={m.admin_tab_tags()}
+ isActive={isActive('tags')}
+ count={tagCount}
+ onTabletTrigger={openFlyout}
+ icon={tagsIcon}
+ />
{/if}
{#if canRunMaintenance}
-
-
-
-
-
-
-
+ label={m.admin_tab_system()}
+ isActive={isActive('system')}
+ topBorder={true}
+ onTabletTrigger={openFlyout}
+ icon={systemIcon}
+ />
{/if}
@@ -360,156 +213,53 @@ function handleKeydown(event: KeyboardEvent) {
{#if canManageUsers}
-
-
-
- {userCount}
-
-
-
+ label={m.admin_tab_users()}
+ isActive={isActive('users')}
+ count={userCount}
+ onFlyoutClick={closeFlyout}
+ icon={usersIcon}
+ />
{/if}
{#if canManagePermissions}
-
-
-
- {groupCount}
-
-
-
+ label={m.admin_tab_groups()}
+ isActive={isActive('groups')}
+ count={groupCount}
+ onFlyoutClick={closeFlyout}
+ icon={groupsIcon}
+ />
{/if}
{#if canManageTags}
-
-
-
- {tagCount}
-
-
-
+ label={m.admin_tab_tags()}
+ isActive={isActive('tags')}
+ count={tagCount}
+ onFlyoutClick={closeFlyout}
+ icon={tagsIcon}
+ />
{/if}
{#if canRunMaintenance}
-
-
-
-
+ label={m.admin_tab_system()}
+ isActive={isActive('system')}
+ topBorder={true}
+ onFlyoutClick={closeFlyout}
+ icon={systemIcon}
+ />
{/if}
{/if}
diff --git a/frontend/src/routes/admin/EntityNavSection.svelte b/frontend/src/routes/admin/EntityNavSection.svelte
new file mode 100644
index 00000000..c255403e
--- /dev/null
+++ b/frontend/src/routes/admin/EntityNavSection.svelte
@@ -0,0 +1,90 @@
+
+
+{#if variant === 'sidebar'}
+
+
+
+
+
+ {@render icon()}
+ {#if count !== undefined}
+ {count}
+ {/if}
+
+
+{:else}
+
+
+ {@render icon()}
+ {#if count !== undefined}
+ {count}
+ {/if}
+
+
+{/if}
diff --git a/frontend/src/routes/admin/EntityNavSection.svelte.spec.ts b/frontend/src/routes/admin/EntityNavSection.svelte.spec.ts
new file mode 100644
index 00000000..0387d638
--- /dev/null
+++ b/frontend/src/routes/admin/EntityNavSection.svelte.spec.ts
@@ -0,0 +1,140 @@
+import { afterEach, describe, it, expect } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+import { createRawSnippet } from 'svelte';
+import EntityNavSection from './EntityNavSection.svelte';
+
+afterEach(cleanup);
+
+const testIcon = createRawSnippet(() => ({
+ render: () => ``,
+ setup: () => {}
+}));
+
+const baseProps = {
+ href: '/admin/users',
+ label: 'Benutzer',
+ icon: testIcon
+};
+
+describe('EntityNavSection — sidebar variant (default)', () => {
+ it('tablet button has border-brand-mint class when isActive=true', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: true });
+ const button = document.querySelector('button[data-flyout-trigger]')!;
+ expect(button.className).toContain('border-brand-mint');
+ });
+
+ it('tablet button has border-transparent class when isActive=false', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false });
+ const button = document.querySelector('button[data-flyout-trigger]')!;
+ expect(button.className).toContain('border-transparent');
+ });
+
+ it('renders count span when count is provided', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, count: 42 });
+ // Sidebar renders two elements (tablet button + desktop link), each with a count span
+ const countSpans = document.querySelectorAll('span');
+ const countTexts = Array.from(countSpans).filter((s) => s.textContent?.trim() === '42');
+ expect(countTexts.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('does not render count span when count is undefined', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false });
+ // No numeric count element — the label text is present but no count span
+ const spans = document.querySelectorAll('button[data-flyout-trigger] span');
+ expect(spans.length).toBe(0);
+ });
+
+ it('desktop link has hidden and lg:flex classes', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false });
+ const link = document.querySelector('a[href="/admin/users"]')!;
+ expect(link.className).toContain('hidden');
+ expect(link.className).toContain('lg:flex');
+ });
+
+ it('desktop link has aria-current=page when isActive=true', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: true });
+ await expect
+ .element(page.getByRole('link', { name: 'Benutzer' }))
+ .toHaveAttribute('aria-current', 'page');
+ });
+
+ it('desktop link does not have aria-current when isActive=false', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false });
+ await expect
+ .element(page.getByRole('link', { name: 'Benutzer' }))
+ .not.toHaveAttribute('aria-current');
+ });
+
+ it('renders the icon in the tablet button', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false });
+ const button = document.querySelector('button[data-flyout-trigger]')!;
+ expect(button.querySelector('svg')).not.toBeNull();
+ });
+
+ it('renders count in desktop link when count is provided', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, count: 7 });
+ const link = document.querySelector('a[href="/admin/users"]')!;
+ expect(link.textContent).toContain('7');
+ });
+});
+
+describe('EntityNavSection — topBorder prop', () => {
+ it('tablet button has border-l-transparent (not border-transparent) when topBorder=true and inactive', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, topBorder: true });
+ const button = document.querySelector('button[data-flyout-trigger]')!;
+ expect(button.className).toContain('border-l-transparent');
+ expect(button.className).not.toContain('border-transparent hover:bg-white/5');
+ });
+
+ it('tablet button still has border-brand-mint when topBorder=true and isActive=true', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: true, topBorder: true });
+ const button = document.querySelector('button[data-flyout-trigger]')!;
+ expect(button.className).toContain('border-brand-mint');
+ });
+});
+
+describe('EntityNavSection — flyout variant', () => {
+ it('renders a single anchor element (no button) in flyout variant', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout' });
+ expect(document.querySelector('button[data-flyout-trigger]')).toBeNull();
+ expect(document.querySelector('a[href="/admin/users"]')).not.toBeNull();
+ });
+
+ it('flyout link has border-brand-mint class when isActive=true', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: true, variant: 'flyout' });
+ const link = document.querySelector('a[href="/admin/users"]')!;
+ expect(link.className).toContain('border-brand-mint');
+ });
+
+ it('flyout link has border-transparent class when isActive=false', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout' });
+ const link = document.querySelector('a[href="/admin/users"]')!;
+ expect(link.className).toContain('border-transparent');
+ });
+
+ it('flyout link shows count when count=42', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: false, variant: 'flyout', count: 42 });
+ await expect.element(page.getByText('42')).toBeInTheDocument();
+ });
+
+ it('flyout link has aria-current=page when isActive=true', async () => {
+ render(EntityNavSection, { ...baseProps, isActive: true, variant: 'flyout' });
+ const link = document.querySelector('a[href="/admin/users"]')!;
+ expect(link.getAttribute('aria-current')).toBe('page');
+ });
+
+ it('flyout link calls onFlyoutClick when clicked', async () => {
+ let called = false;
+ render(EntityNavSection, {
+ ...baseProps,
+ isActive: false,
+ variant: 'flyout',
+ onFlyoutClick: () => {
+ called = true;
+ }
+ });
+ document.querySelector('a[href="/admin/users"]')!.click();
+ expect(called).toBe(true);
+ });
+});