feat(frontend): replace logout button with user avatar dropdown in nav

Show user initials (e.g. MM) in a circular button when name is set,
or a fallback person icon. Clicking opens a dropdown with links to
/profile and a logout form.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-20 23:03:42 +01:00
parent 82c8401167
commit 401a1f359f
2 changed files with 169 additions and 16 deletions

View File

@@ -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);
};
}
</script>
<div class="min-h-screen bg-white" data-hydrated={hydrated || undefined}>
@@ -103,20 +124,66 @@ onMount(() => {
</button>
{/each}
</div>
<form action="/logout" method="POST" use:enhance>
<button
type="submit"
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-50"
/>
{m.nav_logout()}
</button>
</form>
<!-- User menu -->
<div
class="relative"
{@attach clickOutside}
onkeydown={(e) => { if (e.key === 'Escape') userMenuOpen = false; }}
role="none"
>
{#if userInitials}
<button
type="button"
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="flex h-8 w-8 items-center justify-center rounded-full bg-brand-navy font-sans text-xs font-bold text-white transition-opacity hover:opacity-80"
>
{userInitials}
</button>
{:else}
<button
type="button"
aria-label={m.nav_profile()}
aria-expanded={userMenuOpen}
aria-haspopup="true"
onclick={() => (userMenuOpen = !userMenuOpen)}
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:text-brand-navy"
>
<img
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
alt=""
aria-hidden="true"
class="h-4 w-4 opacity-50"
/>
</button>
{/if}
{#if userMenuOpen}
<div
class="absolute top-full right-0 z-50 mt-1 min-w-[10rem] rounded-sm border border-brand-sand bg-white shadow-md"
>
<a
href="/profile"
onclick={() => (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()}
</a>
<div class="border-t border-brand-sand">
<form action="/logout" method="POST" use:enhance>
<button
type="submit"
class="w-full px-4 py-2.5 text-left font-sans text-xs font-bold tracking-widest text-gray-400 uppercase transition-colors hover:bg-brand-sand/40 hover:text-brand-navy"
>
{m.nav_logout()}
</button>
</form>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>

View File

@@ -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: () => '<span></span>' }));
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();
});
});