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:
@@ -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>
|
||||
|
||||
86
frontend/src/routes/layout.svelte.spec.ts
Normal file
86
frontend/src/routes/layout.svelte.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user