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:
Till-JS 2025-11-24 22:01:04 +01:00
parent 22cb7d2c5f
commit afdc30bd5f
11 changed files with 1536 additions and 22 deletions

View 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>

View 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>

View 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>

View 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';

View 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;
}