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

@ -1,48 +1,185 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type CardVariant = 'elevated' | 'outlined' | 'ghost';
type CardVariant = 'elevated' | 'outlined' | 'ghost' | 'filled';
type CardPadding = 'none' | 'sm' | 'md' | 'lg';
interface Props {
/** Visual variant of the card */
variant?: CardVariant;
/** Padding size */
padding?: CardPadding;
/** Make card interactive (adds hover effects) */
interactive?: boolean;
/** Makes card take full width */
fullWidth?: boolean;
/** Additional CSS classes */
class?: string;
/** Click handler */
onclick?: (e: MouseEvent) => void;
/** Header slot */
header?: Snippet;
/** Footer slot */
footer?: Snippet;
/** Main content */
children: Snippet;
}
let {
variant = 'elevated',
padding = 'md',
interactive = false,
fullWidth = false,
class: className = '',
onclick,
header,
footer,
children
}: Props = $props();
const variantClasses: Record<CardVariant, string> = {
elevated: 'bg-menu shadow-md border border-theme',
outlined: 'bg-content border border-theme',
ghost: 'bg-transparent'
};
const paddingClasses: Record<CardPadding, string> = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8'
};
const classes = $derived(
`rounded-lg ${variantClasses[variant]} ${paddingClasses[padding]} ${className}`
);
// Determine if card should be interactive
const isInteractive = $derived(interactive || !!onclick);
</script>
<div
class={classes}
class="card card--{variant} card--padding-{padding} {isInteractive ? 'card--interactive' : ''} {fullWidth ? 'card--full-width' : ''} {className}"
{onclick}
role={onclick ? 'button' : undefined}
tabindex={onclick ? 0 : undefined}
role={isInteractive ? 'button' : undefined}
tabindex={isInteractive ? 0 : undefined}
onkeydown={(e) => {
if (isInteractive && onclick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onclick(e as unknown as MouseEvent);
}
}}
>
{@render children()}
{#if header}
<div class="card__header">
{@render header()}
</div>
{/if}
<div class="card__body">
{@render children()}
</div>
{#if footer}
<div class="card__footer">
{@render footer()}
</div>
{/if}
</div>
<style>
.card {
border-radius: 0.75rem;
overflow: hidden;
transition: all 0.15s ease;
}
/* Variants */
.card--elevated {
background-color: hsl(var(--color-surface-elevated));
border: 1px solid hsl(var(--color-border));
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
}
.card--outlined {
background-color: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
}
.card--ghost {
background-color: transparent;
}
.card--filled {
background-color: hsl(var(--color-muted));
}
/* Padding */
.card--padding-none .card__body {
padding: 0;
}
.card--padding-sm .card__body {
padding: 0.75rem;
}
.card--padding-md .card__body {
padding: 1rem;
}
.card--padding-lg .card__body {
padding: 1.5rem;
}
/* Full width */
.card--full-width {
width: 100%;
}
/* Interactive */
.card--interactive {
cursor: pointer;
}
.card--interactive:hover {
border-color: hsl(var(--color-border-strong));
}
.card--elevated.card--interactive:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.card--interactive:focus-visible {
outline: 2px solid hsl(var(--color-ring));
outline-offset: 2px;
}
/* Header */
.card__header {
padding: 1rem;
border-bottom: 1px solid hsl(var(--color-border));
}
.card--padding-sm .card__header {
padding: 0.75rem;
}
.card--padding-lg .card__header {
padding: 1.25rem 1.5rem;
}
.card--padding-none .card__header {
padding: 0;
border-bottom: none;
}
/* Body */
.card__body {
/* Padding applied via variant classes above */
}
/* Footer */
.card__footer {
padding: 1rem;
border-top: 1px solid hsl(var(--color-border));
background-color: hsl(var(--color-muted) / 0.3);
}
.card--padding-sm .card__footer {
padding: 0.75rem;
}
.card--padding-lg .card__footer {
padding: 1.25rem 1.5rem;
}
.card--padding-none .card__footer {
padding: 0;
border-top: none;
background-color: transparent;
}
</style>

View file

@ -2,8 +2,13 @@
export { Text, Button, Badge, Card } from './atoms';
// Molecules
export { Toggle, Input } from './molecules';
export { Toggle, Input, Select, Textarea, Checkbox } from './molecules';
export type { SelectOption } from './molecules';
// Organisms
export { Modal, AppSlider } from './organisms';
export type { AppItem } from './organisms';
// Navigation
export { NavLink, Navbar, Sidebar } from './navigation';
export type { NavItem, NavbarProps, SidebarProps, NavLinkProps } from './navigation';

View file

@ -0,0 +1,161 @@
<script lang="ts">
interface Props {
/** Whether the checkbox is checked */
checked: boolean;
/** Called when checked state changes */
onchange?: (checked: boolean) => void;
/** Label text */
label?: string;
/** Description text below label */
description?: string;
/** Disable the checkbox */
disabled?: boolean;
/** Show indeterminate state */
indeterminate?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
checked = $bindable(),
onchange,
label,
description,
disabled = false,
indeterminate = false,
class: className = ''
}: Props = $props();
let inputElement: HTMLInputElement | null = $state(null);
$effect(() => {
if (inputElement) {
inputElement.indeterminate = indeterminate;
}
});
function handleChange(e: Event) {
const target = e.target as HTMLInputElement;
checked = target.checked;
onchange?.(target.checked);
}
</script>
<label class="checkbox-wrapper {disabled ? 'checkbox-wrapper--disabled' : ''} {className}">
<div class="checkbox-input-wrapper">
<input
bind:this={inputElement}
type="checkbox"
{checked}
{disabled}
onchange={handleChange}
class="checkbox-input"
/>
<div class="checkbox-box">
{#if indeterminate}
<svg class="checkbox-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{:else if checked}
<svg class="checkbox-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</div>
</div>
{#if label || description}
<div class="checkbox-content">
{#if label}
<span class="checkbox-label">{label}</span>
{/if}
{#if description}
<span class="checkbox-description">{description}</span>
{/if}
</div>
{/if}
</label>
<style>
.checkbox-wrapper {
display: flex;
align-items: flex-start;
gap: 0.75rem;
cursor: pointer;
}
.checkbox-wrapper--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.checkbox-input-wrapper {
position: relative;
flex-shrink: 0;
}
.checkbox-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.checkbox-box {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
background-color: hsl(var(--color-surface));
border: 2px solid hsl(var(--color-border-strong));
border-radius: 0.25rem;
transition: all 0.15s ease;
}
.checkbox-input:hover:not(:disabled) + .checkbox-box {
border-color: hsl(var(--color-primary));
}
.checkbox-input:focus-visible + .checkbox-box {
outline: 2px solid hsl(var(--color-ring));
outline-offset: 2px;
}
.checkbox-input:checked + .checkbox-box,
.checkbox-input:indeterminate + .checkbox-box {
background-color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
}
.checkbox-icon {
width: 0.875rem;
height: 0.875rem;
stroke: hsl(var(--color-primary-foreground));
}
.checkbox-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding-top: 0.0625rem;
}
.checkbox-label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
line-height: 1.25rem;
}
.checkbox-description {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.25;
}
</style>

View file

@ -0,0 +1,160 @@
<script lang="ts">
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface Props {
/** Current selected value */
value: string;
/** Available options */
options: SelectOption[];
/** Called when selection changes */
onchange?: (value: string) => void;
/** Label text */
label?: string;
/** Placeholder text (shown as first disabled option) */
placeholder?: string;
/** Error message */
error?: string;
/** Disable the select */
disabled?: boolean;
/** Mark as required */
required?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
value = $bindable(),
options,
onchange,
label,
placeholder,
error,
disabled = false,
required = false,
class: className = ''
}: Props = $props();
function handleChange(e: Event) {
const target = e.target as HTMLSelectElement;
value = target.value;
onchange?.(target.value);
}
</script>
<div class="select-wrapper {className}">
{#if label}
<label class="select-label">
{label}
{#if required}
<span class="select-required">*</span>
{/if}
</label>
{/if}
<div class="select-container">
<select
{value}
{disabled}
{required}
onchange={handleChange}
class="select-input {error ? 'select-input--error' : ''}"
>
{#if placeholder}
<option value="" disabled selected={!value}>{placeholder}</option>
{/if}
{#each options as option}
<option value={option.value} disabled={option.disabled}>
{option.label}
</option>
{/each}
</select>
<div class="select-icon">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{#if error}
<p class="select-error">{error}</p>
{/if}
</div>
<style>
.select-wrapper {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.select-label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.select-required {
color: hsl(var(--color-error));
margin-left: 0.125rem;
}
.select-container {
position: relative;
}
.select-input {
width: 100%;
appearance: none;
padding: 0.625rem 2.5rem 0.625rem 1rem;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
background-color: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.15s ease;
}
.select-input:hover:not(:disabled) {
border-color: hsl(var(--color-border-strong));
}
.select-input:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.select-input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.select-input--error {
border-color: hsl(var(--color-error));
}
.select-input--error:focus {
border-color: hsl(var(--color-error));
box-shadow: 0 0 0 3px hsl(var(--color-error) / 0.1);
}
.select-icon {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: hsl(var(--color-muted-foreground));
}
.select-error {
font-size: 0.75rem;
color: hsl(var(--color-error));
margin: 0;
}
</style>

View file

@ -0,0 +1,195 @@
<script lang="ts">
interface Props {
/** Current value */
value: string;
/** Called on input */
oninput?: (value: string) => void;
/** Called on change (blur) */
onchange?: (value: string) => void;
/** Label text */
label?: string;
/** Placeholder text */
placeholder?: string;
/** Number of visible rows */
rows?: number;
/** Maximum character count */
maxlength?: number;
/** Show character count */
showCount?: boolean;
/** Error message */
error?: string;
/** Disable the textarea */
disabled?: boolean;
/** Mark as required */
required?: boolean;
/** Enable auto-resize based on content */
autoResize?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
value = $bindable(),
oninput,
onchange,
label,
placeholder,
rows = 3,
maxlength,
showCount = false,
error,
disabled = false,
required = false,
autoResize = false,
class: className = ''
}: Props = $props();
let textareaElement: HTMLTextAreaElement | null = $state(null);
const charCount = $derived(value?.length ?? 0);
const isOverLimit = $derived(maxlength ? charCount > maxlength : false);
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
value = target.value;
oninput?.(target.value);
if (autoResize && textareaElement) {
textareaElement.style.height = 'auto';
textareaElement.style.height = `${textareaElement.scrollHeight}px`;
}
}
function handleChange(e: Event) {
const target = e.target as HTMLTextAreaElement;
onchange?.(target.value);
}
</script>
<div class="textarea-wrapper {className}">
{#if label}
<label class="textarea-label">
{label}
{#if required}
<span class="textarea-required">*</span>
{/if}
</label>
{/if}
<textarea
bind:this={textareaElement}
{value}
{placeholder}
{rows}
{maxlength}
{disabled}
{required}
oninput={handleInput}
onchange={handleChange}
class="textarea-input {error || isOverLimit ? 'textarea-input--error' : ''} {autoResize ? 'textarea-input--auto-resize' : ''}"
></textarea>
<div class="textarea-footer">
{#if error}
<p class="textarea-error">{error}</p>
{:else}
<span></span>
{/if}
{#if showCount || maxlength}
<span class="textarea-count {isOverLimit ? 'textarea-count--error' : ''}">
{charCount}{#if maxlength}/{maxlength}{/if}
</span>
{/if}
</div>
</div>
<style>
.textarea-wrapper {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.textarea-label {
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-foreground));
}
.textarea-required {
color: hsl(var(--color-error));
margin-left: 0.125rem;
}
.textarea-input {
width: 100%;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-family: inherit;
line-height: 1.5;
color: hsl(var(--color-foreground));
background-color: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
resize: vertical;
transition: all 0.15s ease;
}
.textarea-input:hover:not(:disabled) {
border-color: hsl(var(--color-border-strong));
}
.textarea-input:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.textarea-input:disabled {
opacity: 0.5;
cursor: not-allowed;
resize: none;
}
.textarea-input--error {
border-color: hsl(var(--color-error));
}
.textarea-input--error:focus {
border-color: hsl(var(--color-error));
box-shadow: 0 0 0 3px hsl(var(--color-error) / 0.1);
}
.textarea-input--auto-resize {
resize: none;
overflow: hidden;
}
.textarea-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.textarea-footer {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 1.25rem;
}
.textarea-error {
font-size: 0.75rem;
color: hsl(var(--color-error));
margin: 0;
}
.textarea-count {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-left: auto;
}
.textarea-count--error {
color: hsl(var(--color-error));
}
</style>

View file

@ -1,2 +1,6 @@
export { default as Toggle } from './Toggle.svelte';
export { default as Input } from './Input.svelte';
export { default as Select } from './Select.svelte';
export { default as Textarea } from './Textarea.svelte';
export { default as Checkbox } from './Checkbox.svelte';
export type { SelectOption } from './Select.svelte';

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