diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0734b8f5..3dd6aea5 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -6,14 +6,14 @@ import { onMount } from 'svelte'; import { m } from '$lib/paraglide/messages.js'; import { setLocale, getLocale } from '$lib/paraglide/runtime'; -let { children } = $props(); +let { children, data } = $props(); const locales = ['DE', 'EN', 'ES'] as const; const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const; const activeLocale = $derived(getLocale().toUpperCase()); const isAdmin = $derived( - page.data.user?.groups.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')) + data?.user?.groups?.some((g: { permissions: string[] }) => g.permissions.includes('ADMIN')) ); // Set after client-side hydration completes. Used by E2E tests to know the @@ -22,6 +22,27 @@ let hydrated = $state(false); onMount(() => { hydrated = true; }); + +let userMenuOpen = $state(false); + +const userInitials = $derived.by(() => { + const first = data?.user?.firstName?.[0]; + const last = data?.user?.lastName?.[0]; + if (first && last) return (first + last).toUpperCase(); + return null; +}); + +function clickOutside(node: HTMLElement) { + const handleClick = (event: MouseEvent) => { + if (node && !node.contains(event.target as Node) && !event.defaultPrevented) { + userMenuOpen = false; + } + }; + document.addEventListener('click', handleClick, true); + return () => { + document.removeEventListener('click', handleClick, true); + }; +}
@@ -103,20 +124,66 @@ onMount(() => { {/each}
-
- -
+ + +
{ if (e.key === 'Escape') userMenuOpen = false; }} + role="none" + > + {#if userInitials} + + {:else} + + {/if} + + {#if userMenuOpen} +
+ (userMenuOpen = false)} + class="block px-4 py-2.5 font-sans text-xs font-bold tracking-widest text-gray-600 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy" + > + {m.nav_profile()} + +
+
+ +
+
+
+ {/if} +
diff --git a/frontend/src/routes/layout.svelte.spec.ts b/frontend/src/routes/layout.svelte.spec.ts new file mode 100644 index 00000000..add63655 --- /dev/null +++ b/frontend/src/routes/layout.svelte.spec.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import { createRawSnippet } from 'svelte'; + +afterEach(cleanup); + +const emptySnippet = createRawSnippet(() => ({ render: () => '' })); +import Layout from './+layout.svelte'; + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +// Minimal data required by the layout +const makeData = (overrides = {}) => ({ + user: { + id: '1', + username: 'max', + firstName: 'Max', + lastName: 'Müller', + groups: [], + enabled: true, + createdAt: '' + }, + canWrite: true, + ...overrides +}); + +// ─── User avatar ────────────────────────────────────────────────────────────── + +describe('Layout – user avatar button', () => { + it('shows user initials when first and last name are set', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + await expect.element(page.getByRole('button', { name: /MM/ })).toBeInTheDocument(); + }); + + it('shows fallback icon button when names are not set', async () => { + render(Layout, { + data: makeData({ + user: { id: '1', username: 'x', groups: [], enabled: true, createdAt: '' } + }), + children: emptySnippet + }); + // Button should still exist (with aria-label for accessibility) + await expect.element(page.getByRole('button', { name: /Profil/i })).toBeInTheDocument(); + }); +}); + +// ─── Dropdown ───────────────────────────────────────────────────────────────── + +describe('Layout – user dropdown', () => { + it('dropdown is hidden initially', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + await tick(); + await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument(); + }); + + it('opens dropdown on button click', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + await page.getByRole('button', { name: /MM/ }).click(); + await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument(); + }); + + it('profile link points to /profile', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + await page.getByRole('button', { name: /MM/ }).click(); + await expect + .element(page.getByRole('link', { name: /Profil/i })) + .toHaveAttribute('href', '/profile'); + }); + + it('logout button is in the dropdown', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + await page.getByRole('button', { name: /MM/ }).click(); + await expect.element(page.getByRole('button', { name: /Abmelden/i })).toBeInTheDocument(); + }); + + it('closes dropdown when Escape is pressed', async () => { + render(Layout, { data: makeData(), children: emptySnippet }); + const btn = page.getByRole('button', { name: /MM/ }); + await btn.click(); + await expect.element(page.getByRole('link', { name: /Profil/i })).toBeInTheDocument(); + await userEvent.keyboard('{Escape}'); + await tick(); + await expect.element(page.getByRole('link', { name: /Profil/i })).not.toBeInTheDocument(); + }); +});