mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
161
packages/shared-ui/src/molecules/Checkbox.svelte
Normal file
161
packages/shared-ui/src/molecules/Checkbox.svelte
Normal 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>
|
||||
160
packages/shared-ui/src/molecules/Select.svelte
Normal file
160
packages/shared-ui/src/molecules/Select.svelte
Normal 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>
|
||||
195
packages/shared-ui/src/molecules/Textarea.svelte
Normal file
195
packages/shared-ui/src/molecules/Textarea.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
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