mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 02:46:42 +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
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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue