feat(shared-ui): extend PillNavigation with tab groups and migrate Picture app

- Add PillTabGroup component for segmented controls within navigation
- Extend PillNavigation with elements prop supporting tabs and dividers
- Add new types: PillTabOption, PillTabGroupConfig, PillDivider, PillNavElement
- Migrate Picture app from custom Sidebar to shared PillNavigation
- Add transparent filter bars to Gallery and Explore pages
- Add dev credentials auto-fill on logo click in shared LoginPage

🤖 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 06:55:14 +01:00
parent af8bb9bcb0
commit 4eed41499a
8 changed files with 661 additions and 30 deletions

View file

@ -1,17 +1,50 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import Sidebar from '$lib/components/layout/Sidebar.svelte';
import KeyboardShortcutsModal from '$lib/components/ui/KeyboardShortcutsModal.svelte';
import { currentTheme } from '$lib/stores/theme';
import { isSidebarCollapsed, toggleSidebar } from '$lib/stores/sidebar';
import { isUIVisible, toggleUI, showKeyboardShortcuts } from '$lib/stores/ui';
import { viewMode } from '$lib/stores/view';
import { page } from '$app/stores';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillNavElement } from '@manacore/shared-ui';
import KeyboardShortcutsModal from '$lib/components/ui/KeyboardShortcutsModal.svelte';
import { currentTheme, actualMode, toggleThemeMode } from '$lib/stores/theme';
import { isUIVisible, toggleUI, showKeyboardShortcuts } from '$lib/stores/ui';
import { viewMode, setViewMode, type ViewMode } from '$lib/stores/view';
import { browser } from '$app/environment';
let { children } = $props();
// PillNav state
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Load persisted nav state
$effect(() => {
if (browser) {
const savedSidebarMode = localStorage.getItem('picture-nav-sidebar');
const savedCollapsed = localStorage.getItem('picture-nav-collapsed');
if (savedSidebarMode !== null) isSidebarMode = savedSidebarMode === 'true';
if (savedCollapsed !== null) isCollapsed = savedCollapsed === 'true';
}
});
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
if (browser) localStorage.setItem('picture-nav-sidebar', String(isSidebar));
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
if (browser) localStorage.setItem('picture-nav-collapsed', String(collapsed));
}
async function handleLogout() {
await authStore.signOut();
goto('/auth/login');
}
function handleToggleTheme() {
toggleThemeMode();
}
// Client-side auth check
$effect(() => {
if (authStore.initialized && !authStore.loading && !authStore.user) {
@ -19,9 +52,42 @@
}
});
// Navigation items
const navItems: PillNavItem[] = [
{ href: '/app/gallery', label: 'Galerie', icon: 'home' },
{ href: '/app/board', label: 'Moodboards', icon: 'grid' },
{ href: '/app/explore', label: 'Entdecken', icon: 'search' },
{ href: '/app/generate', label: 'Generieren', icon: 'fire' },
{ href: '/app/upload', label: 'Upload', icon: 'upload' },
{ href: '/app/tags', label: 'Tags', icon: 'tag' },
{ href: '/app/archive', label: 'Archiv', icon: 'archive' },
{ href: '/app/subscription', label: 'Abo', icon: 'creditCard' },
];
// View mode options for tab group
const viewModeOptions = [
{ id: 'single', icon: 'list', title: 'Liste (1)' },
{ id: 'grid3', icon: 'grid', title: 'Mittel (2)' },
{ id: 'gridSmall', icon: 'gridSmall', title: 'Klein (3)' },
];
// Elements (divider + view mode tabs)
let elements: PillNavElement[] = $derived([
{ type: 'divider' as const },
{
type: 'tabs' as const,
sectionLabel: 'Ansicht',
options: viewModeOptions,
value: $viewMode === 'grid5' ? 'gridSmall' : $viewMode,
onChange: (id: string) => {
const mode = id === 'gridSmall' ? 'grid5' : id as ViewMode;
setViewMode(mode);
},
},
]);
// Global keyboard shortcuts
function handleKeyDown(e: KeyboardEvent) {
// Ignore if user is typing in an input/textarea
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
@ -65,19 +131,15 @@
break;
case '1':
e.preventDefault();
viewMode.set('single');
setViewMode('single');
break;
case '2':
e.preventDefault();
viewMode.set('grid3');
setViewMode('grid3');
break;
case '3':
e.preventDefault();
viewMode.set('grid5');
break;
case 's':
e.preventDefault();
toggleSidebar();
setViewMode('grid5');
break;
}
}
@ -96,20 +158,34 @@
</div>
{:else if authStore.user}
<div class="min-h-screen" style="background-color: {$currentTheme.background};">
<!-- Sidebar (conditionally visible) -->
<!-- PillNavigation (conditionally visible) -->
{#if $isUIVisible}
<Sidebar />
<PillNavigation
items={navItems}
{elements}
currentPath={$page.url.pathname}
appName="Picture"
homeRoute="/app/gallery"
onLogout={handleLogout}
onToggleTheme={handleToggleTheme}
isDark={$actualMode === 'dark'}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
showThemeToggle={true}
showLanguageSwitcher={false}
primaryColor="#3b82f6"
/>
{/if}
<!-- Main Content Area -->
<main
class="transition-all duration-300 {$isSidebarCollapsed || !$isUIVisible
? 'lg:pl-0'
: 'lg:pl-[17rem]'}"
class="main-content transition-all duration-300"
class:sidebar-mode={isSidebarMode && !isCollapsed && $isUIVisible}
class:floating-mode={!isSidebarMode && !isCollapsed && $isUIVisible}
>
<!-- Desktop: Left padding when sidebar is open -->
<!-- Mobile: Top padding for header + Bottom padding for nav -->
<div class="min-h-screen pb-20 pt-16 lg:pb-0 lg:pt-0">
<div class="min-h-screen">
{@render children?.()}
</div>
</main>
@ -118,3 +194,26 @@
<KeyboardShortcutsModal />
</div>
{/if}
<style>
/* Floating nav mode - add top padding for fixed nav */
.main-content.floating-mode {
padding-top: 80px;
}
/* Sidebar mode - add left padding for sidebar nav */
.main-content.sidebar-mode {
padding-left: 180px;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.main-content.floating-mode {
padding-top: 70px;
}
.main-content.sidebar-mode {
padding-left: 0;
padding-top: 70px;
}
}
</style>

View file

@ -167,6 +167,99 @@
<title>Entdecken - Picture</title>
</svelte:head>
<!-- Filter Bar -->
<div class="sticky top-0 z-20 px-4 py-3">
<div class="flex flex-wrap items-center gap-3">
<!-- Search -->
<div class="relative min-w-[200px] flex-1 max-w-md">
<input
type="text"
value={searchInput}
oninput={handleSearchInput}
placeholder="Bilder suchen..."
class="w-full rounded-full border border-gray-300/50 bg-white/80 px-4 py-2 pl-10 text-sm text-gray-900 placeholder-gray-500 backdrop-blur-xl transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600/50 dark:bg-gray-800/80 dark:text-gray-100 dark:placeholder-gray-400"
/>
<svg
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400 dark:text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{#if searchInput}
<button
onclick={() => {
searchInput = '';
exploreSearchQuery.set('');
loadInitialImages();
}}
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
<!-- Sort -->
<select
value={$exploreSortBy}
onchange={handleSortChange}
class="rounded-full border border-gray-300/50 bg-white/80 px-4 py-2 text-sm text-gray-900 backdrop-blur-xl focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600/50 dark:bg-gray-800/80 dark:text-gray-100"
>
<option value="recent">Neueste</option>
<option value="popular">Beliebt</option>
<option value="trending">Im Trend</option>
</select>
<!-- Favorites Toggle -->
<button
onclick={() => showExploreFavoritesOnly.update((v) => !v)}
class="inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all {$showExploreFavoritesOnly
? 'bg-blue-600 text-white'
: 'bg-gray-100/80 text-gray-700 backdrop-blur-xl hover:bg-gray-200/80 dark:bg-gray-800/80 dark:text-gray-300 dark:hover:bg-gray-700/80'}"
>
<svg
class="h-4 w-4"
fill={$showExploreFavoritesOnly ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<span>Favoriten</span>
</button>
<!-- Reset Filter -->
{#if $exploreSearchQuery || $showExploreFavoritesOnly || $exploreSortBy !== 'recent'}
<button
onclick={() => {
searchInput = '';
exploreSearchQuery.set('');
exploreSortBy.set('recent');
showExploreFavoritesOnly.set(false);
loadInitialImages();
}}
class="text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
>
Zurücksetzen
</button>
{/if}
</div>
</div>
{#if $isLoadingExplore && $exploreImages.length === 0}
<div class="px-4 py-8">
<ImageSkeleton count={20} />

View file

@ -18,6 +18,7 @@
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import ImageSkeleton from '$lib/components/ui/ImageSkeleton.svelte';
import ViewModeSwitcher from '$lib/components/ui/ViewModeSwitcher.svelte';
import TagPills from '$lib/components/tags/TagPills.svelte';
import { onMount } from 'svelte';
let loadingMore = $state(false);
@ -119,6 +120,70 @@
<title>Gallery - Picture</title>
</svelte:head>
<!-- Filter Bar -->
<div class="sticky top-0 z-20 px-4 py-3">
<div class="flex flex-wrap items-center gap-3">
<!-- Favorites Toggle -->
<button
onclick={() => showFavoritesOnly.update((v) => !v)}
class="inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-all {$showFavoritesOnly
? 'bg-blue-600 text-white'
: 'bg-gray-100/80 text-gray-700 backdrop-blur-xl hover:bg-gray-200/80 dark:bg-gray-800/80 dark:text-gray-300 dark:hover:bg-gray-700/80'}"
>
<svg
class="h-4 w-4"
fill={$showFavoritesOnly ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<span>Favoriten</span>
</button>
<!-- Tags -->
{#if $tags.length > 0}
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Tags:</span>
<TagPills />
</div>
{/if}
<!-- Reset Filter -->
{#if $selectedTags.length > 0 || $showFavoritesOnly}
<button
onclick={() => {
selectedTags.set([]);
showFavoritesOnly.set(false);
}}
class="ml-auto text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
>
Filter zurücksetzen
</button>
{/if}
</div>
<!-- Active Filter Summary -->
{#if $selectedTags.length > 0 || $showFavoritesOnly}
<div class="mt-2 rounded-lg bg-blue-50/80 px-3 py-1.5 backdrop-blur-xl dark:bg-blue-900/30">
<p class="text-xs font-medium text-blue-900 dark:text-blue-100">
{#if $showFavoritesOnly && $selectedTags.length > 0}
Favoriten + {$selectedTags.length} {$selectedTags.length === 1 ? 'Tag' : 'Tags'}
{:else if $showFavoritesOnly}
Nur Favoriten
{:else}
{$selectedTags.length} {$selectedTags.length === 1 ? 'Tag' : 'Tags'} ausgewählt
{/if}
</p>
</div>
{/if}
</div>
{#if $isLoading}
<div class="px-4 py-8">
<ImageSkeleton count={20} />

View file

@ -253,6 +253,12 @@
emailInput.focus();
}
}
// Dev credentials auto-fill (only works in development)
function fillDevCredentials() {
email = 'till.schneider@memoro.ai';
password = 'Aa-12345678';
}
</script>
<svelte:head>
@ -283,23 +289,24 @@
<main>
<!-- Top Section - Logo -->
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4"
<button
type="button"
onclick={fillDevCredentials}
class="flex items-center justify-center rounded-full transition-all mb-4 cursor-pointer hover:scale-105 active:scale-95"
class:success-pulse={showSuccess}
style="width: 120px; height: 120px; border: 3px solid {showSuccess
? '#22c55e'
: primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
role="img"
aria-label="{appName} logo"
aria-label="{appName} logo - Click to fill dev credentials"
>
{#if showSuccess}
<Icon name="check" size={55} color="#22c55e" />
{:else}
<Logo size={55} color={primaryColor} />
{/if}
</div>
</button>
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
{appName}
</h1>

View file

@ -1,7 +1,8 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { PillNavItem, PillDropdownItem } from './types';
import type { PillNavItem, PillDropdownItem, PillNavElement, PillTabGroupConfig } from './types';
import PillDropdown from './PillDropdown.svelte';
import PillTabGroup from './PillTabGroup.svelte';
interface Props {
/** Navigation items */
@ -38,6 +39,10 @@
showThemeToggle?: boolean;
/** Primary color for active state (CSS custom property or hex) */
primaryColor?: string;
/** Additional elements (tab groups, dividers) to show after nav items */
elements?: PillNavElement[];
/** Show logout button */
showLogout?: boolean;
}
let {
@ -58,8 +63,23 @@
showLanguageSwitcher = false,
showThemeToggle = true,
primaryColor,
elements = [],
showLogout = true,
}: Props = $props();
// Type guards for elements
function isTabGroup(element: PillNavElement): element is PillTabGroupConfig {
return 'type' in element && element.type === 'tabs';
}
function isDivider(element: PillNavElement): element is { type: 'divider' } {
return 'type' in element && element.type === 'divider';
}
function isNavItem(element: PillNavElement): element is PillNavItem {
return 'href' in element;
}
// Local state for uncontrolled mode
let internalSidebarMode = $state(false);
let internalCollapsed = $state(false);
@ -137,6 +157,10 @@
chevronUp: 'M5 15l7-7 7 7',
chevronLeft: 'M15 19l-7-7 7-7',
menu: 'M4 6h16M4 12h16M4 18h16',
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
grid: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
gridSmall:
'M4 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM10 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM16 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM4 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1v-2zM10 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2zM16 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2z',
};
function getIconPath(name: string): string {
@ -233,6 +257,36 @@
</a>
{/each}
<!-- Additional Elements (Tab Groups, Dividers) -->
{#each elements as element}
{#if isTabGroup(element)}
<PillTabGroup
options={element.options}
value={element.value}
onChange={element.onChange}
sectionLabel={element.sectionLabel}
{isSidebarMode}
{primaryColor}
/>
{:else if isDivider(element)}
<div class="pill-divider" class:sidebar-divider={isSidebarMode}></div>
{:else if isNavItem(element)}
<a href={element.href} class="pill glass-pill" class:active={isActive(element.href)}>
{#if element.icon}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath(element.icon)}
/>
</svg>
{/if}
<span class="pill-label">{element.label}</span>
</a>
{/if}
{/each}
<!-- Language Switcher -->
{#if showLanguageSwitcher && languageItems.length > 0}
<PillDropdown
@ -274,7 +328,7 @@
{/if}
<!-- Logout -->
{#if onLogout}
{#if onLogout && showLogout}
<button onclick={onLogout} class="pill glass-pill logout-pill" title="Logout">
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -429,6 +483,25 @@
color: var(--pill-primary-color, var(--color-primary-500, #f8d62b));
}
/* Divider */
.pill-divider {
width: 1px;
height: 1.5rem;
background: rgba(0, 0, 0, 0.15);
flex-shrink: 0;
margin: 0 0.25rem;
}
:global(.dark) .pill-divider {
background: rgba(255, 255, 255, 0.2);
}
.sidebar-divider {
width: 100%;
height: 1px;
margin: 0.5rem 0;
}
/* Logout pill */
.logout-pill {
color: #dc2626;

View file

@ -0,0 +1,251 @@
<script lang="ts">
import type { PillTabOption } from './types';
interface Props {
/** Tab options to display */
options: PillTabOption[];
/** Currently selected tab id */
value: string;
/** Called when selection changes */
onChange: (id: string) => void;
/** Optional section label */
sectionLabel?: string;
/** Whether in sidebar mode (affects layout) */
isSidebarMode?: boolean;
/** Primary color for active state */
primaryColor?: string;
}
let {
options,
value,
onChange,
sectionLabel,
isSidebarMode = false,
primaryColor,
}: Props = $props();
// Icon SVG paths (same as PillNavigation)
const icons: Record<string, string> = {
list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
grid: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
gridSmall:
'M4 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM10 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM16 5a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1V5zM4 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1v-2zM10 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2zM16 11a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 01-1 1h-2a1 1 0 01-1-1v-2z',
heart:
'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z',
star: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
fire: 'M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z',
trending: 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6',
single: 'M4 6h16M4 12h16M4 18h16',
};
function getIconPath(name: string): string {
return icons[name] || '';
}
function handleClick(optionId: string, disabled?: boolean) {
if (!disabled) {
onChange(optionId);
}
}
</script>
<div class="pill-tab-group" class:sidebar-mode={isSidebarMode}>
{#if sectionLabel && isSidebarMode}
<p class="section-label">{sectionLabel}</p>
{/if}
<div
class="tab-container glass-pill"
class:sidebar-tabs={isSidebarMode}
style={primaryColor ? `--pill-primary-color: ${primaryColor}` : ''}
>
{#each options as option, index}
{#if index > 0}
<div class="tab-divider"></div>
{/if}
<button
onclick={() => handleClick(option.id, option.disabled)}
class="tab-btn"
class:active={value === option.id}
class:disabled={option.disabled}
title={option.title || option.label}
disabled={option.disabled}
>
{#if option.icon}
{#if option.iconSvg}
{@html option.iconSvg}
{:else}
<svg class="tab-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath(option.icon)}
/>
</svg>
{/if}
{/if}
{#if option.label && isSidebarMode}
<span class="tab-label">{option.label}</span>
{/if}
</button>
{/each}
</div>
</div>
<style>
.pill-tab-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #6b7280;
margin: 0;
padding: 0 0.25rem;
}
:global(.dark) .section-label {
color: #9ca3af;
}
.tab-container {
display: flex;
align-items: center;
padding: 0;
gap: 0;
border-radius: 9999px;
}
/* Glass effect */
.glass-pill {
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);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .glass-pill {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
/* Sidebar mode - transparent */
.sidebar-tabs {
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: none;
}
:global(.dark) .sidebar-tabs {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tab-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
transition: all 0.2s;
flex: 1;
}
:global(.dark) .tab-btn {
color: #9ca3af;
}
.tab-btn:first-child {
border-radius: 9999px 0 0 9999px;
}
.tab-btn:last-child {
border-radius: 0 9999px 9999px 0;
}
.tab-btn:only-child {
border-radius: 9999px;
}
.tab-btn:hover:not(.disabled) {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
:global(.dark) .tab-btn:hover:not(.disabled) {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
.tab-btn.active {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 20%,
white 80%
);
color: var(--pill-primary-color, var(--color-primary-500, #3b82f6));
}
:global(.dark) .tab-btn.active {
background: color-mix(
in srgb,
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 30%,
transparent 70%
);
color: var(--pill-primary-color, var(--color-primary-500, #3b82f6));
}
.tab-btn.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tab-divider {
width: 1px;
height: 1rem;
background: rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
:global(.dark) .tab-divider {
background: rgba(255, 255, 255, 0.15);
}
.tab-icon {
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
}
.tab-label {
font-size: 0.8125rem;
font-weight: 500;
white-space: nowrap;
}
/* Sidebar mode adjustments */
.sidebar-mode .tab-container {
width: 100%;
}
.sidebar-mode .tab-btn {
justify-content: flex-start;
padding: 0.5rem 0.625rem;
}
</style>

View file

@ -4,6 +4,7 @@ export { default as Sidebar } from './Sidebar.svelte';
export { default as SidebarSection } from './SidebarSection.svelte';
export { default as PillNavigation } from './PillNavigation.svelte';
export { default as PillDropdown } from './PillDropdown.svelte';
export { default as PillTabGroup } from './PillTabGroup.svelte';
export type {
NavItem,
NavbarProps,
@ -13,4 +14,8 @@ export type {
PillNavItem,
PillDropdownItem,
PillNavigationProps,
PillTabOption,
PillTabGroupConfig,
PillDivider,
PillNavElement,
} from './types';

View file

@ -39,6 +39,44 @@ export interface PillDropdownItem {
active?: boolean;
}
// ============ Pill Tab Group Types ============
export interface PillTabOption {
/** Unique identifier for the tab */
id: string;
/** Icon name (predefined) */
icon?: string;
/** Custom SVG icon HTML */
iconSvg?: string;
/** Optional label (shown in sidebar mode) */
label?: string;
/** Tooltip text */
title?: string;
/** Whether this option is disabled */
disabled?: boolean;
}
export interface PillTabGroupConfig {
/** Discriminator for type checking */
type: 'tabs';
/** Tab options */
options: PillTabOption[];
/** Currently selected tab id */
value: string;
/** Called when selection changes */
onChange: (id: string) => void;
/** Optional section label (shown above in sidebar mode) */
sectionLabel?: string;
}
export interface PillDivider {
/** Discriminator for type checking */
type: 'divider';
}
/** Union type for all elements that can appear in PillNavigation */
export type PillNavElement = PillNavItem | PillTabGroupConfig | PillDivider;
export interface PillNavigationProps {
/** Navigation items */
items: PillNavItem[];