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} + - - - + label={m.admin_tab_users()} + isActive={isActive('users')} + count={userCount} + onTabletTrigger={openFlyout} + icon={usersIcon} + /> {/if} {#if canManagePermissions} - - - - + label={m.admin_tab_groups()} + isActive={isActive('groups')} + count={groupCount} + onTabletTrigger={openFlyout} + icon={groupsIcon} + /> {/if} {#if canManageTags} - - - - + 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} - - - {m.admin_tab_users()} - - + label={m.admin_tab_users()} + isActive={isActive('users')} + count={userCount} + onFlyoutClick={closeFlyout} + icon={usersIcon} + /> {/if} {#if canManagePermissions} - - - - {groupCount} - - - {m.admin_tab_groups()} - - + label={m.admin_tab_groups()} + isActive={isActive('groups')} + count={groupCount} + onFlyoutClick={closeFlyout} + icon={groupsIcon} + /> {/if} {#if canManageTags} - - - - {tagCount} - - - {m.admin_tab_tags()} - - + label={m.admin_tab_tags()} + isActive={isActive('tags')} + count={tagCount} + onFlyoutClick={closeFlyout} + icon={tagsIcon} + /> {/if}
{#if canRunMaintenance} - - - - {m.admin_tab_system()} - - + 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'} + + + + + +{:else} + + + {@render icon()} + {#if count !== undefined} + {count} + {/if} + {label} + +{/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); + }); +});