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"> <script lang="ts">
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { DropdownMenu } from 'bits-ui';
import ThemeToggle from './ThemeToggle.svelte'; import ThemeToggle from './ThemeToggle.svelte';
import LanguageSwitcher from './LanguageSwitcher.svelte'; import LanguageSwitcher from './LanguageSwitcher.svelte';
import SearchTrigger from '$lib/components/search/SearchTrigger.svelte'; import SearchTrigger from '$lib/components/search/SearchTrigger.svelte';
import NotificationBell from '$lib/components/notifications/NotificationBell.svelte'; import NotificationBell from '$lib/components/notifications/NotificationBell.svelte';
import { ui } from '$lib/stores/ui.svelte.js'; import { ui } from '$lib/stores/ui.svelte.js';
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js'; import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
import { goto } from '$app/navigation';
interface Props { interface Props {
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null; user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
@@ -13,8 +15,10 @@
let { user }: Props = $props(); let { user }: Props = $props();
let showUserMenu = $state(false); function submitLogout() {
let showBgMenu = $state(false); const form = document.getElementById('logout-form');
if (form instanceof HTMLFormElement) form.requestSubmit();
}
const bgOptions: { value: BackgroundType; labelKey: string }[] = [ const bgOptions: { value: BackgroundType; labelKey: string }[] = [
{ value: 'mesh', labelKey: 'bg.mesh' }, { value: 'mesh', labelKey: 'bg.mesh' },
@@ -22,20 +26,8 @@
{ value: 'aurora', labelKey: 'bg.aurora' }, { value: 'aurora', labelKey: 'bg.aurora' },
{ value: 'none', labelKey: 'bg.none' } { 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> </script>
<svelte:window onclick={handleClickOutside} />
<header <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" 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> </div>
<!-- Background selector --> <!-- Background selector -->
<div class="bg-menu-container relative"> <DropdownMenu.Root>
<button <DropdownMenu.Trigger
type="button"
onclick={() => (showBgMenu = !showBgMenu)}
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" 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')} title={$t('bg.title')}
aria-label={$t('bg.aria_label')} 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" /> <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" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
</button> </DropdownMenu.Trigger>
<DropdownMenu.Portal>
{#if showBgMenu} <DropdownMenu.Content
<div class="z-50 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
class="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-popover p-1 shadow-lg" sideOffset={4}
align="end"
> >
{#each bgOptions as opt (opt.value)} {#each bgOptions as opt (opt.value)}
<button <DropdownMenu.Item
type="button" 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
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
? 'bg-accent text-accent-foreground' ? 'bg-accent text-accent-foreground'
: 'text-popover-foreground hover:bg-accent/50'}" : 'text-popover-foreground hover:bg-accent/50'}"
onSelect={() => theme.setBackground(opt.value)}
> >
{#if theme.backgroundType === opt.value} {#if theme.backgroundType === opt.value}
<svg <svg
@@ -123,11 +110,11 @@
<span class="h-3 w-3"></span> <span class="h-3 w-3"></span>
{/if} {/if}
{$t(opt.labelKey)} {$t(opt.labelKey)}
</button> </DropdownMenu.Item>
{/each} {/each}
</div> </DropdownMenu.Content>
{/if} </DropdownMenu.Portal>
</div> </DropdownMenu.Root>
<!-- Notifications bell (authenticated users only) --> <!-- Notifications bell (authenticated users only) -->
{#if user} {#if user}
@@ -142,10 +129,8 @@
<!-- User menu --> <!-- User menu -->
{#if user} {#if user}
<div class="user-menu-container relative"> <DropdownMenu.Root>
<button <DropdownMenu.Trigger
type="button"
onclick={() => (showUserMenu = !showUserMenu)}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
> >
<span <span
@@ -156,21 +141,21 @@
{#if !ui.isMobile} {#if !ui.isMobile}
<span class="max-w-[120px] truncate text-sm">{user.displayName}</span> <span class="max-w-[120px] truncate text-sm">{user.displayName}</span>
{/if} {/if}
</button> </DropdownMenu.Trigger>
<DropdownMenu.Portal>
{#if showUserMenu} <DropdownMenu.Content
<div class="z-50 w-48 rounded-md border border-border bg-popover p-1 shadow-lg"
class="absolute right-0 top-full mt-1 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"> <div class="border-b border-border px-3 py-2">
<p class="text-sm font-medium text-popover-foreground">{user.displayName}</p> <p class="text-sm font-medium text-popover-foreground">{user.displayName}</p>
<p class="truncate text-xs text-muted-foreground">{user.email}</p> <p class="truncate text-xs text-muted-foreground">{user.email}</p>
</div> </div>
<a <DropdownMenu.Item
href="/settings" 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"
onclick={() => (showUserMenu = false)} onSelect={() => goto('/settings')}
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"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -186,12 +171,11 @@
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
{$t('settings.title')} {$t('settings.title')}
</a> </DropdownMenu.Item>
<a <DropdownMenu.Item
href="/settings/api-tokens" 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"
onclick={() => (showUserMenu = false)} onSelect={() => goto('/settings/api-tokens')}
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"
> >
<svg <svg
class="h-4 w-4" class="h-4 w-4"
@@ -207,12 +191,14 @@
<path d="M7 11V7a5 5 0 0 1 10 0v4" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
API Tokens API Tokens
</a> </DropdownMenu.Item>
<form method="POST" action="/auth/logout"> <DropdownMenu.Separator class="my-1 border-t border-border" />
<button
type="submit" <form method="POST" action="/auth/logout" id="logout-form" class="contents">
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.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 <svg
class="h-4 w-4" class="h-4 w-4"
@@ -229,11 +215,11 @@
<line x1="21" y1="12" x2="9" y2="12" /> <line x1="21" y1="12" x2="9" y2="12" />
</svg> </svg>
{$t('auth.logout')} {$t('auth.logout')}
</button> </DropdownMenu.Item>
</form> </form>
</div> </DropdownMenu.Content>
{/if} </DropdownMenu.Portal>
</div> </DropdownMenu.Root>
{:else} {:else}
<a <a
href="/login" href="/login"
+46 -26
View File
@@ -19,6 +19,8 @@
let { boards, isAdmin, collapsed }: Props = $props(); let { boards, isAdmin, collapsed }: Props = $props();
let boardsExpanded = $state(true);
function isActive(path: string): boolean { function isActive(path: string): boolean {
return $page.url.pathname.startsWith(path); return $page.url.pathname.startsWith(path);
} }
@@ -158,36 +160,54 @@
{#if boards.length > 0} {#if boards.length > 0}
<div class="mb-3"> <div class="mb-3">
{#if !collapsed} {#if !collapsed}
<p <button
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50" 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')} <span>{$t('nav.boards')}</span>
</p> <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}
{#each boards as board (board.id)} {#if boardsExpanded || collapsed}
<a <div class="max-h-48 overflow-y-auto">
href="/boards/{board.id}" {#each boards as board (board.id)}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors {isActive(`/boards/${board.id}`) <a
? 'bg-sidebar-accent text-sidebar-accent-foreground' href="/boards/{board.id}"
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}" class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors {isActive(`/boards/${board.id}`)
title={collapsed ? board.name : undefined} ? 'bg-sidebar-accent text-sidebar-accent-foreground'
onclick={() => ui.closeMobileSidebar()} : 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
> title={collapsed ? board.name : undefined}
{#if board.icon} onclick={() => ui.closeMobileSidebar()}
<span class="shrink-0"><DynamicIcon name={board.icon} size={18} /></span>
{:else}
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-sidebar-accent text-[10px] font-medium text-sidebar-foreground"
> >
{board.name.charAt(0).toUpperCase()} {#if board.icon}
</span> <span class="shrink-0"><DynamicIcon name={board.icon} size={18} /></span>
{/if} {:else}
{#if !collapsed} <span
<span class="truncate">{board.name}</span> class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-sidebar-accent text-[10px] font-medium text-sidebar-foreground"
{/if} >
</a> {board.name.charAt(0).toUpperCase()}
{/each} </span>
{/if}
{#if !collapsed}
<span class="truncate">{board.name}</span>
{/if}
</a>
{/each}
</div>
{/if}
</div> </div>
{/if} {/if}