feat(shared-ui): add theme mode selector to PillNavigation dropdown

Integrate Light/Dark/System mode toggle into the theme variant dropdown
as a header section with icon-only buttons. The standalone theme toggle
is now hidden when showThemeVariants is enabled.

- Add header snippet support to PillDropdown component
- Add themeMode and onThemeModeChange props to PillNavigation
- Create compact 3-button mode selector (sun/moon/monitor icons)
- Style mode selector to match glass-pill design system

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 13:20:43 +01:00
parent f436fbb99d
commit 3cfa6a765a
3 changed files with 166 additions and 10 deletions

View file

@ -47,13 +47,17 @@
// Navigation items for Chat
const navItems: PillNavItem[] = [
{ href: '/', label: 'Chat', icon: 'home' },
{ href: '/chat', label: 'Chat', icon: 'home' },
{ href: '/templates', label: 'Templates', icon: 'document' },
{ href: '/spaces', label: 'Spaces', icon: 'building' },
{ href: '/documents', label: 'Dokumente', icon: 'archive' },
{ href: '/archive', label: 'Archiv', icon: 'list' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
];
// Check if current page is a chat page (needs full-width layout)
let isChatPage = $derived($page.url.pathname.startsWith('/chat'));
// Navigation shortcuts (Ctrl+1-5)
const navRoutes = navItems.map((item) => item.href);
@ -96,6 +100,10 @@
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
await authStore.signOut();
goto('/login');
@ -152,7 +160,7 @@
items={navItems}
currentPath={$page.url.pathname}
appName="ManaChat"
homeRoute="/"
homeRoute="/chat"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
@ -163,6 +171,8 @@
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={false}
showLogout={true}
onLogout={handleLogout}
@ -174,10 +184,16 @@
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
class:chat-page={isChatPage}
>
<div class="content-wrapper">
{#if isChatPage}
<!-- Full-width layout for chat pages -->
{@render children()}
</div>
{:else}
<div class="content-wrapper">
{@render children()}
</div>
{/if}
</main>
</div>
{/if}
@ -204,6 +220,11 @@
padding-left: 180px;
}
/* Chat page - no content wrapper, but keep nav padding */
.main-content.chat-page {
overflow: hidden;
}
.content-wrapper {
max-width: 80rem;
margin-left: auto;

View file

@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { PillDropdownItem } from './types';
interface Props {
@ -8,9 +9,11 @@
icon?: 'globe' | 'language' | 'chevronDown' | 'check' | string;
isOpen?: boolean;
onToggle?: (open: boolean) => void;
/** Optional header content (e.g., mode selector) */
header?: Snippet;
}
let { items, direction = 'down', label, icon, isOpen = false, onToggle }: Props = $props();
let { items, direction = 'down', label, icon, isOpen = false, onToggle, header }: Props = $props();
let internalOpen = $state(false);
let triggerButton: HTMLButtonElement;
@ -115,13 +118,20 @@
class:fan-down={direction === 'down'}
style="top: {dropdownPosition.top}px; left: {dropdownPosition.left}px;"
>
<!-- Optional header (e.g., mode selector) -->
{#if header}
<div class="dropdown-header">
{@render header()}
</div>
{/if}
{#each items.filter((i) => !i.disabled) as item, i (item.id)}
<button
onclick={() => handleItemClick(item)}
class="pill glass-pill fan-pill"
class:danger-pill={item.danger}
class:active-pill={item.active}
style="animation-delay: {i * 15}ms"
style="animation-delay: {(header ? i + 1 : i) * 15}ms"
>
{#if item.icon}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -299,4 +309,15 @@
border: none;
cursor: default;
}
/* Header for custom content (e.g., mode selector) */
.dropdown-header {
animation: fanIn 0.15s ease-out forwards;
opacity: 0;
transform: translateY(10px);
}
.fan-up .dropdown-header {
transform: translateY(-10px);
}
</style>

View file

@ -35,7 +35,7 @@
currentLanguageLabel?: string;
/** Show language switcher */
showLanguageSwitcher?: boolean;
/** Show theme toggle */
/** Show theme toggle (standalone button, hidden if showThemeVariants is true) */
showThemeToggle?: boolean;
/** Primary color for active state (CSS custom property or hex) */
primaryColor?: string;
@ -49,6 +49,10 @@
currentThemeVariantLabel?: string;
/** Show theme variant selector */
showThemeVariants?: boolean;
/** Current theme mode ('light', 'dark', 'system') */
themeMode?: 'light' | 'dark' | 'system';
/** Called when theme mode changes */
onThemeModeChange?: (mode: 'light' | 'dark' | 'system') => void;
}
let {
@ -74,6 +78,8 @@
themeVariantItems = [],
currentThemeVariantLabel = 'Theme',
showThemeVariants = false,
themeMode = 'system',
onThemeModeChange,
}: Props = $props();
// Type guards for elements
@ -315,11 +321,50 @@
direction="down"
label={currentThemeVariantLabel}
icon="palette"
/>
>
{#snippet header()}
<div class="theme-mode-selector">
<button
type="button"
onclick={() => onThemeModeChange?.('light')}
class="mode-btn"
class:active={themeMode === 'light'}
title="Light mode"
>
<svg class="mode-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getIconPath('sun')} />
</svg>
</button>
<button
type="button"
onclick={() => onThemeModeChange?.('dark')}
class="mode-btn"
class:active={themeMode === 'dark'}
title="Dark mode"
>
<svg class="mode-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={getIconPath('moon')} />
</svg>
</button>
<button
type="button"
onclick={() => onThemeModeChange?.('system')}
class="mode-btn"
class:active={themeMode === 'system'}
title="System mode"
>
<svg class="mode-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="2" y="3" width="20" height="14" rx="2" stroke-width="2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 21h8M12 17v4" />
</svg>
</button>
</div>
{/snippet}
</PillDropdown>
{/if}
<!-- Theme Toggle -->
{#if showThemeToggle && onToggleTheme}
<!-- Theme Toggle (only show when not using theme variants dropdown) -->
{#if showThemeToggle && onToggleTheme && !showThemeVariants}
<button
onclick={onToggleTheme}
class="pill glass-pill"
@ -723,4 +768,73 @@
.pill-nav-container {
transition: all 0.3s ease;
}
/* Theme mode selector in dropdown header */
.theme-mode-selector {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 9999px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .theme-mode-selector {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.mode-btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 0.375rem;
border: none;
background: transparent;
border-radius: 9999px;
cursor: pointer;
color: #374151;
transition: all 0.15s;
}
:global(.dark) .mode-btn {
color: #f3f4f6;
}
.mode-btn:hover:not(.active) {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .mode-btn:hover:not(.active) {
background: rgba(255, 255, 255, 0.1);
}
.mode-btn.active {
background: var(--pill-primary-color, var(--color-primary-500, rgba(248, 214, 43, 0.2)));
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 20%,
white 80%
);
}
:global(.dark) .mode-btn.active {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 30%,
transparent 70%
);
}
.mode-icon {
width: 1rem;
height: 1rem;
}
</style>