refactor: header user menu with bits-ui dropdown, collapsible sidebar boards

- Replace manual click-outside menu with DropdownMenu from bits-ui
- Add collapsible boards section in sidebar with chevron toggle
- Add max-height scroll for boards list
This commit is contained in:
2026-04-10 19:06:29 +03:00
parent 44e1849821
commit b5166d9768
2 changed files with 93 additions and 87 deletions
+47 -61
View File
@@ -1,11 +1,13 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import { DropdownMenu } from 'bits-ui';
import ThemeToggle from './ThemeToggle.svelte';
import LanguageSwitcher from './LanguageSwitcher.svelte';
import SearchTrigger from '$lib/components/search/SearchTrigger.svelte';
import NotificationBell from '$lib/components/notifications/NotificationBell.svelte';
import { ui } from '$lib/stores/ui.svelte.js';
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
import { goto } from '$app/navigation';
interface Props {
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
@@ -13,8 +15,10 @@
let { user }: Props = $props();
let showUserMenu = $state(false);
let showBgMenu = $state(false);
function submitLogout() {
const form = document.getElementById('logout-form');
if (form instanceof HTMLFormElement) form.requestSubmit();
}
const bgOptions: { value: BackgroundType; labelKey: string }[] = [
{ value: 'mesh', labelKey: 'bg.mesh' },
@@ -22,20 +26,8 @@
{ value: 'aurora', labelKey: 'bg.aurora' },
{ value: 'none', labelKey: 'bg.none' }
];
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.user-menu-container')) {
showUserMenu = false;
}
if (!target.closest('.bg-menu-container')) {
showBgMenu = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<header
class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-sm"
>
@@ -70,10 +62,8 @@
</div>
<!-- Background selector -->
<div class="bg-menu-container relative">
<button
type="button"
onclick={() => (showBgMenu = !showBgMenu)}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title={$t('bg.title')}
aria-label={$t('bg.aria_label')}
@@ -91,22 +81,19 @@
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
{#if showBgMenu}
<div
class="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="z-50 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
sideOffset={4}
align="end"
>
{#each bgOptions as opt (opt.value)}
<button
type="button"
onclick={() => {
theme.setBackground(opt.value);
showBgMenu = false;
}}
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors {theme.backgroundType === opt.value
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors {theme.backgroundType === opt.value
? 'bg-accent text-accent-foreground'
: 'text-popover-foreground hover:bg-accent/50'}"
onSelect={() => theme.setBackground(opt.value)}
>
{#if theme.backgroundType === opt.value}
<svg
@@ -123,11 +110,11 @@
<span class="h-3 w-3"></span>
{/if}
{$t(opt.labelKey)}
</button>
</DropdownMenu.Item>
{/each}
</div>
{/if}
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<!-- Notifications bell (authenticated users only) -->
{#if user}
@@ -142,10 +129,8 @@
<!-- User menu -->
{#if user}
<div class="user-menu-container relative">
<button
type="button"
onclick={() => (showUserMenu = !showUserMenu)}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<span
@@ -156,21 +141,21 @@
{#if !ui.isMobile}
<span class="max-w-[120px] truncate text-sm">{user.displayName}</span>
{/if}
</button>
{#if showUserMenu}
<div
class="absolute right-0 top-full mt-1 w-48 rounded-md border border-border bg-popover p-1 shadow-lg"
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
class="z-50 w-48 rounded-md border border-border bg-popover p-1 shadow-lg"
sideOffset={4}
align="end"
>
<div class="border-b border-border px-3 py-2">
<p class="text-sm font-medium text-popover-foreground">{user.displayName}</p>
<p class="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
<a
href="/settings"
onclick={() => (showUserMenu = false)}
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
onSelect={() => goto('/settings')}
>
<svg
class="h-4 w-4"
@@ -186,12 +171,11 @@
<circle cx="12" cy="12" r="3" />
</svg>
{$t('settings.title')}
</a>
</DropdownMenu.Item>
<a
href="/settings/api-tokens"
onclick={() => (showUserMenu = false)}
class="flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
onSelect={() => goto('/settings/api-tokens')}
>
<svg
class="h-4 w-4"
@@ -207,12 +191,14 @@
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
API Tokens
</a>
</DropdownMenu.Item>
<form method="POST" action="/auth/logout">
<button
type="submit"
class="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
<DropdownMenu.Separator class="my-1 border-t border-border" />
<form method="POST" action="/auth/logout" id="logout-form" class="contents">
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
onSelect={submitLogout}
>
<svg
class="h-4 w-4"
@@ -229,11 +215,11 @@
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
{$t('auth.logout')}
</button>
</DropdownMenu.Item>
</form>
</div>
{/if}
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{:else}
<a
href="/login"
+24 -4
View File
@@ -19,6 +19,8 @@
let { boards, isAdmin, collapsed }: Props = $props();
let boardsExpanded = $state(true);
function isActive(path: string): boolean {
return $page.url.pathname.startsWith(path);
}
@@ -158,13 +160,29 @@
{#if boards.length > 0}
<div class="mb-3">
{#if !collapsed}
<p
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50"
<button
type="button"
onclick={() => (boardsExpanded = !boardsExpanded)}
class="mb-1 flex w-full items-center justify-between px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground/80"
>
{$t('nav.boards')}
</p>
<span>{$t('nav.boards')}</span>
<svg
class="h-3 w-3 transition-transform duration-200"
class:rotate-180={boardsExpanded}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{/if}
{#if boardsExpanded || collapsed}
<div class="max-h-48 overflow-y-auto">
{#each boards as board (board.id)}
<a
href="/boards/{board.id}"
@@ -190,6 +208,8 @@
{/each}
</div>
{/if}
</div>
{/if}
<!-- Admin -->
{#if isAdmin}