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