mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 20:41:23 +02:00
feat(shared-ui): add navigation components and form elements
NEW COMPONENTS: Navigation: - NavLink: Reusable navigation link with active state, tooltips, badges - Navbar: Horizontal top navigation with mobile menu support - Sidebar: Vertical collapsible sidebar with theme toggle support Form Elements: - Select: Dropdown select with placeholder, error states - Textarea: Multi-line input with auto-resize, character count - Checkbox: Accessible checkbox with indeterminate state support ENHANCED: - Card: Added header/footer slots, interactive mode, filled variant EXPORTS: - NavItem, NavbarProps, SidebarProps, NavLinkProps types - SelectOption type for Select component All components use HSL CSS variables from shared-tailwind for consistent theming across all apps. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
22cb7d2c5f
commit
afdc30bd5f
11 changed files with 1536 additions and 22 deletions
210
packages/shared-ui/src/navigation/NavLink.svelte
Normal file
210
packages/shared-ui/src/navigation/NavLink.svelte
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<script lang="ts">
|
||||
import type { NavLinkProps } from './types';
|
||||
|
||||
let {
|
||||
item,
|
||||
active = false,
|
||||
variant = 'default',
|
||||
minimized = false,
|
||||
class: className = ''
|
||||
}: NavLinkProps = $props();
|
||||
|
||||
let showTooltip = $state(false);
|
||||
|
||||
function handleMouseEnter() {
|
||||
if (minimized) {
|
||||
showTooltip = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
showTooltip = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={item.href}
|
||||
class="nav-link nav-link--{variant} {active ? 'nav-link--active' : ''} {minimized ? 'nav-link--minimized' : ''} {className}"
|
||||
class:nav-link--disabled={item.disabled}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-disabled={item.disabled}
|
||||
>
|
||||
{#if item.icon}
|
||||
<span class="nav-link__icon">
|
||||
{#if item.icon.startsWith('<svg') || item.icon.startsWith('M')}
|
||||
<!-- SVG path or element -->
|
||||
{@html item.icon.startsWith('M') ? `<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="${item.icon}"/></svg>` : item.icon}
|
||||
{:else}
|
||||
<!-- Emoji or text icon -->
|
||||
<span class="text-lg">{item.icon}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if !minimized}
|
||||
<span class="nav-link__label">{item.label}</span>
|
||||
{/if}
|
||||
|
||||
{#if item.badge !== undefined && !minimized}
|
||||
<span class="nav-link__badge">{item.badge}</span>
|
||||
{/if}
|
||||
|
||||
{#if item.shortcut && !minimized}
|
||||
<kbd class="nav-link__shortcut">{item.shortcut}</kbd>
|
||||
{/if}
|
||||
|
||||
{#if minimized && showTooltip}
|
||||
<span class="nav-link__tooltip">{item.label}</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-link:hover:not(.nav-link--disabled) {
|
||||
background-color: hsl(var(--color-surface-hover));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.nav-link--active {
|
||||
background-color: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.nav-link--active:hover {
|
||||
background-color: hsl(var(--color-primary) / 0.15);
|
||||
}
|
||||
|
||||
.nav-link--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Sidebar variant */
|
||||
.nav-link--sidebar {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mobile variant */
|
||||
.nav-link--mobile {
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Pill variant */
|
||||
.nav-link--pill {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 9999px;
|
||||
background-color: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.nav-link--pill:hover:not(.nav-link--disabled) {
|
||||
background-color: hsl(var(--color-surface-hover));
|
||||
}
|
||||
|
||||
.nav-link--pill.nav-link--active {
|
||||
background-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
|
||||
/* Minimized sidebar */
|
||||
.nav-link--minimized {
|
||||
justify-content: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* Icon */
|
||||
.nav-link__icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.nav-link__icon :global(svg) {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Label */
|
||||
.nav-link__label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.nav-link__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.375rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
background-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Shortcut */
|
||||
.nav-link__shortcut {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background-color: hsl(var(--color-muted));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.25rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.nav-link__tooltip {
|
||||
position: fixed;
|
||||
left: calc(100% + 0.75rem);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background-color: hsl(var(--color-foreground));
|
||||
color: hsl(var(--color-background));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.375rem;
|
||||
white-space: nowrap;
|
||||
z-index: 50;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-link__tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-right-color: hsl(var(--color-foreground));
|
||||
}
|
||||
</style>
|
||||
270
packages/shared-ui/src/navigation/Navbar.svelte
Normal file
270
packages/shared-ui/src/navigation/Navbar.svelte
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { NavItem } from './types';
|
||||
import NavLink from './NavLink.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Navigation items to display */
|
||||
items: NavItem[];
|
||||
/** Logo snippet */
|
||||
logo?: Snippet;
|
||||
/** App name to display next to logo */
|
||||
appName?: string;
|
||||
/** Logo href */
|
||||
logoHref?: string;
|
||||
/** Current pathname for active state detection */
|
||||
currentPath?: string;
|
||||
/** User email to display */
|
||||
userEmail?: string;
|
||||
/** Called when sign out is clicked */
|
||||
onSignOut?: () => void;
|
||||
/** Sign out button label */
|
||||
signOutLabel?: string;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
logo,
|
||||
appName = '',
|
||||
logoHref = '/',
|
||||
currentPath = '',
|
||||
userEmail = '',
|
||||
onSignOut,
|
||||
signOutLabel = 'Sign Out',
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/') return currentPath === '/';
|
||||
return currentPath.startsWith(href);
|
||||
}
|
||||
|
||||
function toggleMobileMenu() {
|
||||
mobileMenuOpen = !mobileMenuOpen;
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="navbar {className}">
|
||||
<div class="navbar__container">
|
||||
<div class="navbar__content">
|
||||
<!-- Logo -->
|
||||
<div class="navbar__brand">
|
||||
<a href={logoHref} class="navbar__logo">
|
||||
{#if logo}
|
||||
{@render logo()}
|
||||
{/if}
|
||||
{#if appName}
|
||||
<span class="navbar__app-name">{appName}</span>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="navbar__nav">
|
||||
{#each items as item}
|
||||
<NavLink {item} active={isActive(item.href)} variant="default" />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- User Section -->
|
||||
<div class="navbar__user">
|
||||
{#if userEmail}
|
||||
<span class="navbar__email">{userEmail}</span>
|
||||
{/if}
|
||||
{#if onSignOut}
|
||||
<button onclick={onSignOut} class="navbar__signout">
|
||||
{signOutLabel}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
class="navbar__mobile-toggle"
|
||||
onclick={toggleMobileMenu}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{#if mobileMenuOpen}
|
||||
<svg class="w-6 h-6" 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>
|
||||
{:else}
|
||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
{#if mobileMenuOpen}
|
||||
<div class="navbar__mobile-menu">
|
||||
<div class="navbar__mobile-nav">
|
||||
{#each items as item}
|
||||
<NavLink {item} active={isActive(item.href)} variant="mobile" />
|
||||
{/each}
|
||||
</div>
|
||||
{#if onSignOut}
|
||||
<div class="navbar__mobile-footer">
|
||||
<button onclick={onSignOut} class="navbar__mobile-signout">
|
||||
{signOutLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
background-color: hsl(var(--color-surface-elevated));
|
||||
}
|
||||
|
||||
.navbar__container {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.navbar__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
/* Brand/Logo */
|
||||
.navbar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.navbar__app-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Desktop Navigation */
|
||||
.navbar__nav {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navbar__nav {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* User Section */
|
||||
.navbar__user {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navbar__user {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar__email {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.navbar__signout {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-error));
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.navbar__signout:hover {
|
||||
background-color: hsl(var(--color-error) / 0.1);
|
||||
}
|
||||
|
||||
/* Mobile Menu Toggle */
|
||||
.navbar__mobile-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navbar__mobile-toggle:hover {
|
||||
background-color: hsl(var(--color-surface-hover));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navbar__mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile Menu */
|
||||
.navbar__mobile-menu {
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.navbar__mobile-menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar__mobile-nav {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.navbar__mobile-footer {
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.navbar__mobile-signout {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-error));
|
||||
background: transparent;
|
||||
border: 1px solid hsl(var(--color-error) / 0.3);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.navbar__mobile-signout:hover {
|
||||
background-color: hsl(var(--color-error) / 0.1);
|
||||
}
|
||||
</style>
|
||||
289
packages/shared-ui/src/navigation/Sidebar.svelte
Normal file
289
packages/shared-ui/src/navigation/Sidebar.svelte
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { NavItem } from './types';
|
||||
import NavLink from './NavLink.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Navigation items to display */
|
||||
items: NavItem[];
|
||||
/** Logo snippet */
|
||||
logo?: Snippet;
|
||||
/** App name to display */
|
||||
appName?: string;
|
||||
/** Logo href */
|
||||
logoHref?: string;
|
||||
/** Current pathname for active state detection */
|
||||
currentPath?: string;
|
||||
/** Whether sidebar is minimized/collapsed */
|
||||
minimized?: boolean;
|
||||
/** Called when minimize toggle is clicked */
|
||||
onToggleMinimize?: () => void;
|
||||
/** User email to display */
|
||||
userEmail?: string;
|
||||
/** Called when sign out is clicked */
|
||||
onSignOut?: () => void;
|
||||
/** Sign out button label */
|
||||
signOutLabel?: string;
|
||||
/** Show theme toggle */
|
||||
showThemeToggle?: boolean;
|
||||
/** Called when theme toggle is clicked */
|
||||
onToggleTheme?: () => void;
|
||||
/** Current theme mode (for icon display) */
|
||||
isDark?: boolean;
|
||||
/** Light mode label */
|
||||
lightModeLabel?: string;
|
||||
/** Dark mode label */
|
||||
darkModeLabel?: string;
|
||||
/** Minimize label */
|
||||
minimizeLabel?: string;
|
||||
/** Expand label */
|
||||
expandLabel?: string;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
/** Footer content slot */
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
logo,
|
||||
appName = '',
|
||||
logoHref = '/',
|
||||
currentPath = '',
|
||||
minimized = false,
|
||||
onToggleMinimize,
|
||||
userEmail = '',
|
||||
onSignOut,
|
||||
signOutLabel = 'Sign Out',
|
||||
showThemeToggle = false,
|
||||
onToggleTheme,
|
||||
isDark = false,
|
||||
lightModeLabel = 'Light Mode',
|
||||
darkModeLabel = 'Dark Mode',
|
||||
minimizeLabel = 'Minimize',
|
||||
expandLabel = 'Expand',
|
||||
class: className = '',
|
||||
footer
|
||||
}: Props = $props();
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/') return currentPath === '/';
|
||||
return currentPath.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="sidebar {minimized ? 'sidebar--minimized' : ''} {className}">
|
||||
<div class="sidebar__inner">
|
||||
<!-- Logo/Brand -->
|
||||
<div class="sidebar__header">
|
||||
<a href={logoHref} class="sidebar__logo">
|
||||
{#if logo}
|
||||
{@render logo()}
|
||||
{/if}
|
||||
{#if appName && !minimized}
|
||||
<span class="sidebar__app-name">{appName}</span>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar__nav">
|
||||
{#each items as item}
|
||||
<NavLink
|
||||
{item}
|
||||
active={isActive(item.href)}
|
||||
variant="sidebar"
|
||||
{minimized}
|
||||
/>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sidebar__footer">
|
||||
{#if footer}
|
||||
{@render footer()}
|
||||
{/if}
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
{#if showThemeToggle && onToggleTheme}
|
||||
<button
|
||||
onclick={onToggleTheme}
|
||||
class="sidebar__action"
|
||||
title={isDark ? lightModeLabel : darkModeLabel}
|
||||
>
|
||||
{#if isDark}
|
||||
<!-- Sun icon -->
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Moon icon -->
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if !minimized}
|
||||
<span>{isDark ? lightModeLabel : darkModeLabel}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- User Email -->
|
||||
{#if userEmail && !minimized}
|
||||
<div class="sidebar__email">{userEmail}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sign Out -->
|
||||
{#if onSignOut}
|
||||
<button
|
||||
onclick={onSignOut}
|
||||
class="sidebar__action sidebar__action--danger"
|
||||
title={signOutLabel}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{#if !minimized}
|
||||
<span>{signOutLabel}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Toggle Minimize -->
|
||||
{#if onToggleMinimize}
|
||||
<button
|
||||
onclick={onToggleMinimize}
|
||||
class="sidebar__action"
|
||||
title={minimized ? expandLabel : minimizeLabel}
|
||||
>
|
||||
{#if minimized}
|
||||
<!-- Menu icon (expand) -->
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Chevron left (minimize) -->
|
||||
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>{minimizeLabel}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
flex-shrink: 0;
|
||||
transition: width 0.2s ease;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.sidebar--minimized {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.sidebar__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: hsl(var(--color-surface));
|
||||
border-right: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
/* Header/Logo */
|
||||
.sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.sidebar__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.sidebar--minimized .sidebar__logo {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar__app-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.sidebar__nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.sidebar__footer {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.sidebar__email {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sidebar__action:hover {
|
||||
background-color: hsl(var(--color-surface-hover));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.sidebar--minimized .sidebar__action {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar__action--danger {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.sidebar__action--danger:hover {
|
||||
background-color: hsl(var(--color-error) / 0.1);
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
/* Icon sizing */
|
||||
.sidebar__action :global(svg) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
4
packages/shared-ui/src/navigation/index.ts
Normal file
4
packages/shared-ui/src/navigation/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { default as NavLink } from './NavLink.svelte';
|
||||
export { default as Navbar } from './Navbar.svelte';
|
||||
export { default as Sidebar } from './Sidebar.svelte';
|
||||
export type { NavItem, NavbarProps, SidebarProps, NavLinkProps } from './types';
|
||||
79
packages/shared-ui/src/navigation/types.ts
Normal file
79
packages/shared-ui/src/navigation/types.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { Snippet } from 'svelte';
|
||||
|
||||
export interface NavItem {
|
||||
/** Display label for the navigation item */
|
||||
label: string;
|
||||
/** URL to navigate to */
|
||||
href: string;
|
||||
/** Icon - can be emoji, SVG path, or component name */
|
||||
icon?: string;
|
||||
/** Whether this item is currently active */
|
||||
active?: boolean;
|
||||
/** Badge text (e.g., notification count) */
|
||||
badge?: string | number;
|
||||
/** Whether the item is disabled */
|
||||
disabled?: boolean;
|
||||
/** Keyboard shortcut hint */
|
||||
shortcut?: string;
|
||||
}
|
||||
|
||||
export interface NavbarProps {
|
||||
/** Navigation items to display */
|
||||
items: NavItem[];
|
||||
/** Logo snippet or component */
|
||||
logo?: Snippet;
|
||||
/** App name to display next to logo */
|
||||
appName?: string;
|
||||
/** Current pathname for active state detection */
|
||||
currentPath?: string;
|
||||
/** User email to display */
|
||||
userEmail?: string;
|
||||
/** Show mobile menu */
|
||||
showMobile?: boolean;
|
||||
/** Called when sign out is clicked */
|
||||
onSignOut?: () => void;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
/** Navigation items to display */
|
||||
items: NavItem[];
|
||||
/** Logo snippet or component */
|
||||
logo?: Snippet;
|
||||
/** App name to display */
|
||||
appName?: string;
|
||||
/** Current pathname for active state detection */
|
||||
currentPath?: string;
|
||||
/** Whether sidebar is minimized/collapsed */
|
||||
minimized?: boolean;
|
||||
/** Called when minimize toggle is clicked */
|
||||
onToggleMinimize?: () => void;
|
||||
/** User email to display */
|
||||
userEmail?: string;
|
||||
/** Called when sign out is clicked */
|
||||
onSignOut?: () => void;
|
||||
/** Show theme toggle */
|
||||
showThemeToggle?: boolean;
|
||||
/** Called when theme toggle is clicked */
|
||||
onToggleTheme?: () => void;
|
||||
/** Current theme mode (for icon display) */
|
||||
isDark?: boolean;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
/** Footer items (shortcuts, etc.) */
|
||||
footerItems?: NavItem[];
|
||||
}
|
||||
|
||||
export interface NavLinkProps {
|
||||
/** Navigation item data */
|
||||
item: NavItem;
|
||||
/** Whether the link is active */
|
||||
active?: boolean;
|
||||
/** Display variant */
|
||||
variant?: 'default' | 'sidebar' | 'mobile' | 'pill';
|
||||
/** Whether in minimized sidebar mode (show tooltip) */
|
||||
minimized?: boolean;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue