1206 lines
38 KiB
Markdown
1206 lines
38 KiB
Markdown
|
|
# Component Cookbook
|
||
|
|
|
||
|
|
Comprehensive examples for all vapora-leptos-ui components.
|
||
|
|
|
||
|
|
## Table of Contents
|
||
|
|
|
||
|
|
- [Primitives](#primitives)
|
||
|
|
- [Button](#button)
|
||
|
|
- [Input](#input)
|
||
|
|
- [Badge](#badge)
|
||
|
|
- [Spinner](#spinner)
|
||
|
|
- [Layout](#layout)
|
||
|
|
- [Card](#card)
|
||
|
|
- [Modal](#modal)
|
||
|
|
- [Data](#data)
|
||
|
|
- [Table](#table)
|
||
|
|
- [Pagination](#pagination)
|
||
|
|
- [StatCard](#statcard)
|
||
|
|
- [Forms](#forms)
|
||
|
|
- [FormField](#formfield)
|
||
|
|
- [Validation](#validation)
|
||
|
|
- [Feedback](#feedback)
|
||
|
|
- [Toast](#toast)
|
||
|
|
- [Navigation](#navigation)
|
||
|
|
- [SpaLink](#spalink)
|
||
|
|
- [Patterns](#patterns)
|
||
|
|
- [Modal with Form](#modal-with-form)
|
||
|
|
- [Table with Sorting and Pagination](#table-with-sorting-and-pagination)
|
||
|
|
- [Dashboard with Stats](#dashboard-with-stats)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Primitives
|
||
|
|
|
||
|
|
### Button
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::{Button, Variant, Size};
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn ButtonExamples() -> impl IntoView {
|
||
|
|
let (count, set_count) = signal(0);
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<div class="space-y-4">
|
||
|
|
// Primary button
|
||
|
|
<Button
|
||
|
|
variant=Variant::Primary
|
||
|
|
size=Size::Large
|
||
|
|
on_click=Some(Callback::new(move |_| {
|
||
|
|
set_count.update(|c| *c += 1);
|
||
|
|
}))
|
||
|
|
>
|
||
|
|
{move || format!("Clicked {} times", count.get())}
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
// Secondary button
|
||
|
|
<Button variant=Variant::Secondary size=Size::Medium>
|
||
|
|
"Cancel"
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
// Danger button
|
||
|
|
<Button variant=Variant::Danger size=Size::Small>
|
||
|
|
"Delete"
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
// Ghost button
|
||
|
|
<Button variant=Variant::Ghost>
|
||
|
|
"Subtle Action"
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
// Loading button
|
||
|
|
<Button variant=Variant::Primary loading=true>
|
||
|
|
"Saving..."
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
// Disabled button
|
||
|
|
<Button variant=Variant::Primary disabled=true>
|
||
|
|
"Disabled"
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Input
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::Input;
|
||
|
|
|
||
|
|
/// Extract value from input event (Leptos 0.8 helper)
|
||
|
|
#[cfg(target_arch = "wasm32")]
|
||
|
|
fn event_target_value(ev: &leptos::ev::Event) -> String {
|
||
|
|
use wasm_bindgen::JsCast;
|
||
|
|
ev.target()
|
||
|
|
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
|
||
|
|
.map(|input| input.value())
|
||
|
|
.unwrap_or_default()
|
||
|
|
}
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn InputExamples() -> impl IntoView {
|
||
|
|
let (username, set_username) = signal(String::new());
|
||
|
|
let (password, set_password) = signal(String::new());
|
||
|
|
let (email, set_email) = signal(String::new());
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<div class="space-y-4">
|
||
|
|
// Text input
|
||
|
|
<Input
|
||
|
|
input_type="text"
|
||
|
|
placeholder="Enter username"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_username.set(event_target_value(&ev));
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
|
||
|
|
// Password input
|
||
|
|
<Input
|
||
|
|
input_type="password"
|
||
|
|
placeholder="Enter password"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_password.set(event_target_value(&ev));
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
|
||
|
|
// Email input
|
||
|
|
<Input
|
||
|
|
input_type="email"
|
||
|
|
placeholder="Enter email"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_email.set(event_target_value(&ev));
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
|
||
|
|
// Disabled input
|
||
|
|
<Input
|
||
|
|
input_type="text"
|
||
|
|
placeholder="Disabled field"
|
||
|
|
disabled=true
|
||
|
|
on_input=Callback::new(|_| {})
|
||
|
|
/>
|
||
|
|
|
||
|
|
// Display values
|
||
|
|
<div class="text-white">
|
||
|
|
<div>"Username: " {move || username.get()}</div>
|
||
|
|
<div>"Password: " {move || "*".repeat(password.get().len())}</div>
|
||
|
|
<div>"Email: " {move || email.get()}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Badge
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::Badge;
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn BadgeExamples() -> impl IntoView {
|
||
|
|
view! {
|
||
|
|
<div class="flex gap-2">
|
||
|
|
<Badge class="bg-green-500/20 text-green-400">"Active"</Badge>
|
||
|
|
<Badge class="bg-blue-500/20 text-blue-400">"Pending"</Badge>
|
||
|
|
<Badge class="bg-red-500/20 text-red-400">"Error"</Badge>
|
||
|
|
<Badge class="bg-purple-500/20 text-purple-400">"Premium"</Badge>
|
||
|
|
<Badge class="bg-gray-500/20 text-gray-400">"Archived"</Badge>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Spinner
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::{Spinner, Size};
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn SpinnerExamples() -> impl IntoView {
|
||
|
|
view! {
|
||
|
|
<div class="flex gap-8 items-center">
|
||
|
|
<div class="flex flex-col items-center gap-2">
|
||
|
|
<Spinner size=Size::Small />
|
||
|
|
<span class="text-white text-sm">"Small"</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="flex flex-col items-center gap-2">
|
||
|
|
<Spinner size=Size::Medium />
|
||
|
|
<span class="text-white text-sm">"Medium"</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="flex flex-col items-center gap-2">
|
||
|
|
<Spinner size=Size::Large />
|
||
|
|
<span class="text-white text-sm">"Large"</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Layout
|
||
|
|
|
||
|
|
### Card
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::{Card, GlowColor, Badge};
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn CardExamples() -> impl IntoView {
|
||
|
|
view! {
|
||
|
|
<div class="grid grid-cols-3 gap-6">
|
||
|
|
// Basic card
|
||
|
|
<Card>
|
||
|
|
<h3 class="text-lg font-semibold text-white mb-2">"Basic Card"</h3>
|
||
|
|
<p class="text-gray-400">"Simple glassmorphism card"</p>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
// Hoverable card with glow
|
||
|
|
<Card hoverable=true glow=GlowColor::Cyan>
|
||
|
|
<h3 class="text-lg font-semibold text-cyan-400 mb-2">"Hoverable"</h3>
|
||
|
|
<p class="text-gray-400">"Hover for effect"</p>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
// Card with content
|
||
|
|
<Card glow=GlowColor::Purple>
|
||
|
|
<div class="flex items-center justify-between mb-2">
|
||
|
|
<h3 class="text-lg font-semibold text-purple-400">"Project"</h3>
|
||
|
|
<Badge class="bg-green-500/20 text-green-400">"Active"</Badge>
|
||
|
|
</div>
|
||
|
|
<p class="text-gray-400 text-sm mb-3">
|
||
|
|
"Multi-agent development platform"
|
||
|
|
</p>
|
||
|
|
<div class="flex gap-2">
|
||
|
|
<Badge class="bg-blue-500/20 text-blue-400">"Rust"</Badge>
|
||
|
|
<Badge class="bg-purple-500/20 text-purple-400">"Leptos"</Badge>
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Modal
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::{Modal, Button, Variant};
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn ModalExample() -> impl IntoView {
|
||
|
|
let (show_modal, set_show_modal) = signal(false);
|
||
|
|
let (show_large_modal, set_show_large_modal) = signal(false);
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<div class="space-y-4">
|
||
|
|
// Trigger buttons
|
||
|
|
<Button
|
||
|
|
variant=Variant::Primary
|
||
|
|
on_click=Some(Callback::new(move |_| set_show_modal.set(true)))
|
||
|
|
>
|
||
|
|
"Open Modal"
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant=Variant::Secondary
|
||
|
|
on_click=Some(Callback::new(move |_| set_show_large_modal.set(true)))
|
||
|
|
>
|
||
|
|
"Open Large Modal"
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
// Simple modal
|
||
|
|
<Show when=move || show_modal.get()>
|
||
|
|
<Modal on_close=Callback::new(move |_| set_show_modal.set(false))>
|
||
|
|
<h2 class="text-2xl font-bold text-white mb-4">"Confirmation"</h2>
|
||
|
|
<p class="text-gray-300 mb-6">
|
||
|
|
"Are you sure you want to proceed?"
|
||
|
|
</p>
|
||
|
|
<div class="flex gap-3 justify-end">
|
||
|
|
<Button
|
||
|
|
variant=Variant::Secondary
|
||
|
|
on_click=Some(Callback::new(move |_| set_show_modal.set(false)))
|
||
|
|
>
|
||
|
|
"Cancel"
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant=Variant::Primary
|
||
|
|
on_click=Some(Callback::new(move |_| {
|
||
|
|
set_show_modal.set(false);
|
||
|
|
// Perform action
|
||
|
|
}))
|
||
|
|
>
|
||
|
|
"Confirm"
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</Modal>
|
||
|
|
</Show>
|
||
|
|
|
||
|
|
// Large modal with content
|
||
|
|
<Show when=move || show_large_modal.get()>
|
||
|
|
<Modal on_close=Callback::new(move |_| set_show_large_modal.set(false))>
|
||
|
|
<div class="max-w-2xl">
|
||
|
|
<h2 class="text-2xl font-bold text-white mb-4">"Project Details"</h2>
|
||
|
|
<div class="space-y-4 text-gray-300">
|
||
|
|
<p>"This is a large modal with more content."</p>
|
||
|
|
<p>"Modal features:"</p>
|
||
|
|
<ul class="list-disc list-inside">
|
||
|
|
<li>"Press Escape to close"</li>
|
||
|
|
<li>"Click backdrop to close"</li>
|
||
|
|
<li>"Tab navigation trapped inside"</li>
|
||
|
|
<li>"Auto-focus on first focusable element"</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
<div class="mt-6 flex justify-end">
|
||
|
|
<Button
|
||
|
|
variant=Variant::Primary
|
||
|
|
on_click=Some(Callback::new(move |_| {
|
||
|
|
set_show_large_modal.set(false);
|
||
|
|
}))
|
||
|
|
>
|
||
|
|
"Close"
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</Modal>
|
||
|
|
</Show>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Data
|
||
|
|
|
||
|
|
### Table
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::{Table, TableColumn};
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn TableExample() -> impl IntoView {
|
||
|
|
let columns = vec![
|
||
|
|
TableColumn::new("Name", "name").sortable(),
|
||
|
|
TableColumn::new("Email", "email").sortable(),
|
||
|
|
TableColumn::new("Status", "status").sortable(),
|
||
|
|
TableColumn::new("Role", "role"),
|
||
|
|
];
|
||
|
|
|
||
|
|
let rows = vec![
|
||
|
|
vec![
|
||
|
|
"Alice Smith".to_string(),
|
||
|
|
"alice@example.com".to_string(),
|
||
|
|
"Active".to_string(),
|
||
|
|
"Admin".to_string(),
|
||
|
|
],
|
||
|
|
vec![
|
||
|
|
"Bob Johnson".to_string(),
|
||
|
|
"bob@example.com".to_string(),
|
||
|
|
"Inactive".to_string(),
|
||
|
|
"User".to_string(),
|
||
|
|
],
|
||
|
|
vec![
|
||
|
|
"Carol Williams".to_string(),
|
||
|
|
"carol@example.com".to_string(),
|
||
|
|
"Active".to_string(),
|
||
|
|
"Moderator".to_string(),
|
||
|
|
],
|
||
|
|
];
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<Table columns=columns rows=rows />
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Pagination
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::Pagination;
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn PaginationExample() -> impl IntoView {
|
||
|
|
let (current_page, set_current_page) = signal(1usize);
|
||
|
|
let total_pages = 10;
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<div class="space-y-4">
|
||
|
|
<div class="text-white text-center">
|
||
|
|
"Current page: " {move || current_page.get()}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Pagination
|
||
|
|
current_page=current_page.get()
|
||
|
|
total_pages=total_pages
|
||
|
|
on_page_change=Callback::new(move |page| {
|
||
|
|
set_current_page.set(page);
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### StatCard
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::StatCard;
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn StatCardExamples() -> impl IntoView {
|
||
|
|
view! {
|
||
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||
|
|
<StatCard
|
||
|
|
label="Total Users".to_string()
|
||
|
|
value="1,234".to_string()
|
||
|
|
/>
|
||
|
|
|
||
|
|
<StatCard
|
||
|
|
label="Active Projects".to_string()
|
||
|
|
value="42".to_string()
|
||
|
|
trend_positive=true
|
||
|
|
/>
|
||
|
|
|
||
|
|
<StatCard
|
||
|
|
label="Revenue".to_string()
|
||
|
|
value="$12,345".to_string()
|
||
|
|
trend_positive=true
|
||
|
|
/>
|
||
|
|
|
||
|
|
<StatCard
|
||
|
|
label="Pending Tasks".to_string()
|
||
|
|
value="8".to_string()
|
||
|
|
trend_positive=false
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Forms
|
||
|
|
|
||
|
|
### FormField
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::{FormField, Input, Button, Variant};
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn FormFieldExample() -> impl IntoView {
|
||
|
|
let (username, set_username) = signal(String::new());
|
||
|
|
let (email, set_email) = signal(String::new());
|
||
|
|
let (username_error, set_username_error) = signal::<Option<String>>(None);
|
||
|
|
|
||
|
|
let handle_submit = move |ev: leptos::ev::SubmitEvent| {
|
||
|
|
ev.prevent_default();
|
||
|
|
|
||
|
|
// Validate
|
||
|
|
if username.get().trim().is_empty() {
|
||
|
|
set_username_error.set(Some("Username is required".to_string()));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Submit form
|
||
|
|
// ...
|
||
|
|
};
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<form on:submit=handle_submit class="space-y-4">
|
||
|
|
// Field with error
|
||
|
|
{move || {
|
||
|
|
if let Some(err) = username_error.get() {
|
||
|
|
view! {
|
||
|
|
<FormField
|
||
|
|
label="Username".to_string()
|
||
|
|
error=err
|
||
|
|
required=true
|
||
|
|
has_error=true
|
||
|
|
>
|
||
|
|
<Input
|
||
|
|
input_type="text"
|
||
|
|
placeholder="Enter username"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_username.set(event_target_value(&ev));
|
||
|
|
set_username_error.set(None);
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
}.into_any()
|
||
|
|
} else {
|
||
|
|
view! {
|
||
|
|
<FormField
|
||
|
|
label="Username".to_string()
|
||
|
|
required=true
|
||
|
|
has_error=false
|
||
|
|
>
|
||
|
|
<Input
|
||
|
|
input_type="text"
|
||
|
|
placeholder="Enter username"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_username.set(event_target_value(&ev));
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
}.into_any()
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
|
||
|
|
// Field with help text
|
||
|
|
<FormField
|
||
|
|
label="Email".to_string()
|
||
|
|
help_text="We'll never share your email".to_string()
|
||
|
|
required=false
|
||
|
|
has_error=false
|
||
|
|
>
|
||
|
|
<Input
|
||
|
|
input_type="email"
|
||
|
|
placeholder="you@example.com"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_email.set(event_target_value(&ev));
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
|
||
|
|
<Button variant=Variant::Primary button_type="submit">
|
||
|
|
"Submit"
|
||
|
|
</Button>
|
||
|
|
</form>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Validation
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::{
|
||
|
|
validate_required, validate_email, validate_min_length, validate_max_length,
|
||
|
|
FormField, Input, Button, Variant
|
||
|
|
};
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn ValidationExample() -> impl IntoView {
|
||
|
|
let (email, set_email) = signal(String::new());
|
||
|
|
let (password, set_password) = signal(String::new());
|
||
|
|
let (email_error, set_email_error) = signal::<Option<String>>(None);
|
||
|
|
let (password_error, set_password_error) = signal::<Option<String>>(None);
|
||
|
|
|
||
|
|
let validate_form = move || -> bool {
|
||
|
|
let mut valid = true;
|
||
|
|
|
||
|
|
// Validate email
|
||
|
|
if let Err(err) = validate_required(&email.get(), "Email") {
|
||
|
|
set_email_error.set(Some(err));
|
||
|
|
valid = false;
|
||
|
|
} else if let Err(err) = validate_email(&email.get()) {
|
||
|
|
set_email_error.set(Some(err));
|
||
|
|
valid = false;
|
||
|
|
} else {
|
||
|
|
set_email_error.set(None);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate password
|
||
|
|
if let Err(err) = validate_required(&password.get(), "Password") {
|
||
|
|
set_password_error.set(Some(err));
|
||
|
|
valid = false;
|
||
|
|
} else if let Err(err) = validate_min_length(&password.get(), 8, "Password") {
|
||
|
|
set_password_error.set(Some(err));
|
||
|
|
valid = false;
|
||
|
|
} else if let Err(err) = validate_max_length(&password.get(), 100, "Password") {
|
||
|
|
set_password_error.set(Some(err));
|
||
|
|
valid = false;
|
||
|
|
} else {
|
||
|
|
set_password_error.set(None);
|
||
|
|
}
|
||
|
|
|
||
|
|
valid
|
||
|
|
};
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<form on:submit=move |ev| {
|
||
|
|
ev.prevent_default();
|
||
|
|
if validate_form() {
|
||
|
|
// Submit
|
||
|
|
}
|
||
|
|
} class="space-y-4">
|
||
|
|
// Email field with validation
|
||
|
|
{move || {
|
||
|
|
if let Some(err) = email_error.get() {
|
||
|
|
view! {
|
||
|
|
<FormField label="Email".to_string() error=err required=true has_error=true>
|
||
|
|
<Input
|
||
|
|
input_type="email"
|
||
|
|
placeholder="you@example.com"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_email.set(event_target_value(&ev));
|
||
|
|
set_email_error.set(None);
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
}.into_any()
|
||
|
|
} else {
|
||
|
|
view! {
|
||
|
|
<FormField label="Email".to_string() required=true has_error=false>
|
||
|
|
<Input
|
||
|
|
input_type="email"
|
||
|
|
placeholder="you@example.com"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_email.set(event_target_value(&ev));
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
}.into_any()
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
|
||
|
|
// Password field with validation
|
||
|
|
{move || {
|
||
|
|
if let Some(err) = password_error.get() {
|
||
|
|
view! {
|
||
|
|
<FormField
|
||
|
|
label="Password".to_string()
|
||
|
|
error=err
|
||
|
|
required=true
|
||
|
|
has_error=true
|
||
|
|
help_text="8-100 characters".to_string()
|
||
|
|
>
|
||
|
|
<Input
|
||
|
|
input_type="password"
|
||
|
|
placeholder="Enter password"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_password.set(event_target_value(&ev));
|
||
|
|
set_password_error.set(None);
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
}.into_any()
|
||
|
|
} else {
|
||
|
|
view! {
|
||
|
|
<FormField
|
||
|
|
label="Password".to_string()
|
||
|
|
required=true
|
||
|
|
has_error=false
|
||
|
|
help_text="8-100 characters".to_string()
|
||
|
|
>
|
||
|
|
<Input
|
||
|
|
input_type="password"
|
||
|
|
placeholder="Enter password"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_password.set(event_target_value(&ev));
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
}.into_any()
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
|
||
|
|
<Button variant=Variant::Primary button_type="submit">
|
||
|
|
"Sign Up"
|
||
|
|
</Button>
|
||
|
|
</form>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Feedback
|
||
|
|
|
||
|
|
### Toast
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::{ToastProvider, use_toast, ToastType, Button, Variant};
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn ToastExample() -> impl IntoView {
|
||
|
|
view! {
|
||
|
|
<ToastProvider>
|
||
|
|
<ToastButtons />
|
||
|
|
</ToastProvider>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn ToastButtons() -> impl IntoView {
|
||
|
|
let toast = use_toast();
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<div class="space-x-4">
|
||
|
|
<Button
|
||
|
|
variant=Variant::Primary
|
||
|
|
on_click=Some(Callback::new(move |_| {
|
||
|
|
toast.show_toast(
|
||
|
|
"Operation successful!".to_string(),
|
||
|
|
ToastType::Success
|
||
|
|
);
|
||
|
|
}))
|
||
|
|
>
|
||
|
|
"Show Success"
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant=Variant::Danger
|
||
|
|
on_click=Some(Callback::new(move |_| {
|
||
|
|
toast.show_toast(
|
||
|
|
"Something went wrong!".to_string(),
|
||
|
|
ToastType::Error
|
||
|
|
);
|
||
|
|
}))
|
||
|
|
>
|
||
|
|
"Show Error"
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant=Variant::Secondary
|
||
|
|
on_click=Some(Callback::new(move |_| {
|
||
|
|
toast.show_toast(
|
||
|
|
"Here's some information".to_string(),
|
||
|
|
ToastType::Info
|
||
|
|
);
|
||
|
|
}))
|
||
|
|
>
|
||
|
|
"Show Info"
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Navigation
|
||
|
|
|
||
|
|
### SpaLink
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::SpaLink;
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn SpaLinkExamples() -> impl IntoView {
|
||
|
|
view! {
|
||
|
|
<div class="space-y-4">
|
||
|
|
// Internal link (SPA navigation)
|
||
|
|
<SpaLink
|
||
|
|
href="/projects".to_string()
|
||
|
|
class=Some("text-cyan-400 hover:text-cyan-300".to_string())
|
||
|
|
>
|
||
|
|
"View Projects"
|
||
|
|
</SpaLink>
|
||
|
|
|
||
|
|
// External link (opens in new tab)
|
||
|
|
<SpaLink
|
||
|
|
href="https://github.com".to_string()
|
||
|
|
external=true
|
||
|
|
class=Some("text-purple-400 hover:text-purple-300".to_string())
|
||
|
|
>
|
||
|
|
"GitHub"
|
||
|
|
</SpaLink>
|
||
|
|
|
||
|
|
// Mailto link (external)
|
||
|
|
<SpaLink
|
||
|
|
href="mailto:support@example.com".to_string()
|
||
|
|
class=Some("text-blue-400 hover:text-blue-300".to_string())
|
||
|
|
>
|
||
|
|
"Email Support"
|
||
|
|
</SpaLink>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Patterns
|
||
|
|
|
||
|
|
### Modal with Form
|
||
|
|
|
||
|
|
Complete example of a modal containing a validated form:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::{
|
||
|
|
Modal, FormField, Input, Button, Variant, use_toast, ToastType,
|
||
|
|
validate_required
|
||
|
|
};
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn CreateProjectModal() -> impl IntoView {
|
||
|
|
let (show_modal, set_show_modal) = signal(false);
|
||
|
|
let (title, set_title) = signal(String::new());
|
||
|
|
let (description, set_description) = signal(String::new());
|
||
|
|
let (error, set_error) = signal::<Option<String>>(None);
|
||
|
|
let toast = use_toast();
|
||
|
|
|
||
|
|
let handle_submit = move |ev: leptos::ev::SubmitEvent| {
|
||
|
|
ev.prevent_default();
|
||
|
|
|
||
|
|
// Validate
|
||
|
|
if let Err(err) = validate_required(&title.get(), "Project title") {
|
||
|
|
set_error.set(Some(err));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Submit (in real app, call API)
|
||
|
|
toast.show_toast(
|
||
|
|
format!("Created project: {}", title.get()),
|
||
|
|
ToastType::Success
|
||
|
|
);
|
||
|
|
|
||
|
|
// Reset and close
|
||
|
|
set_title.set(String::new());
|
||
|
|
set_description.set(String::new());
|
||
|
|
set_error.set(None);
|
||
|
|
set_show_modal.set(false);
|
||
|
|
};
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<div>
|
||
|
|
<Button
|
||
|
|
variant=Variant::Primary
|
||
|
|
on_click=Some(Callback::new(move |_| set_show_modal.set(true)))
|
||
|
|
>
|
||
|
|
"+ New Project"
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
<Show when=move || show_modal.get()>
|
||
|
|
<Modal on_close=Callback::new(move |_| {
|
||
|
|
set_show_modal.set(false);
|
||
|
|
set_title.set(String::new());
|
||
|
|
set_description.set(String::new());
|
||
|
|
set_error.set(None);
|
||
|
|
})>
|
||
|
|
<h2 class="text-2xl font-bold text-white mb-6">"Create New Project"</h2>
|
||
|
|
|
||
|
|
<form on:submit=handle_submit>
|
||
|
|
<div class="flex flex-col gap-4">
|
||
|
|
// Title field with validation
|
||
|
|
{move || {
|
||
|
|
if let Some(err) = error.get() {
|
||
|
|
view! {
|
||
|
|
<FormField
|
||
|
|
label="Project Title".to_string()
|
||
|
|
error=err
|
||
|
|
required=true
|
||
|
|
has_error=true
|
||
|
|
>
|
||
|
|
<Input
|
||
|
|
input_type="text"
|
||
|
|
placeholder="Enter project name"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_title.set(event_target_value(&ev));
|
||
|
|
set_error.set(None);
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
}.into_any()
|
||
|
|
} else {
|
||
|
|
view! {
|
||
|
|
<FormField
|
||
|
|
label="Project Title".to_string()
|
||
|
|
required=true
|
||
|
|
has_error=false
|
||
|
|
>
|
||
|
|
<Input
|
||
|
|
input_type="text"
|
||
|
|
placeholder="Enter project name"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_title.set(event_target_value(&ev));
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
}.into_any()
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
|
||
|
|
// Description field (optional)
|
||
|
|
<FormField
|
||
|
|
label="Description".to_string()
|
||
|
|
help_text="Optional project description".to_string()
|
||
|
|
required=false
|
||
|
|
has_error=false
|
||
|
|
>
|
||
|
|
<Input
|
||
|
|
input_type="text"
|
||
|
|
placeholder="Describe your project"
|
||
|
|
on_input=Callback::new(move |ev| {
|
||
|
|
set_description.set(event_target_value(&ev));
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</FormField>
|
||
|
|
|
||
|
|
// Action buttons
|
||
|
|
<div class="flex gap-3 justify-end mt-4">
|
||
|
|
<Button
|
||
|
|
variant=Variant::Secondary
|
||
|
|
on_click=Some(Callback::new(move |_| {
|
||
|
|
set_show_modal.set(false);
|
||
|
|
}))
|
||
|
|
>
|
||
|
|
"Cancel"
|
||
|
|
</Button>
|
||
|
|
<Button variant=Variant::Primary button_type="submit">
|
||
|
|
"Create Project"
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</Modal>
|
||
|
|
</Show>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Table with Sorting and Pagination
|
||
|
|
|
||
|
|
Complete example of a data table with sorting and pagination:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::{Table, TableColumn, Pagination, Spinner};
|
||
|
|
|
||
|
|
const ITEMS_PER_PAGE: usize = 10;
|
||
|
|
|
||
|
|
#[derive(Clone, Debug)]
|
||
|
|
struct User {
|
||
|
|
id: String,
|
||
|
|
name: String,
|
||
|
|
email: String,
|
||
|
|
status: String,
|
||
|
|
role: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn UsersTable() -> impl IntoView {
|
||
|
|
let (users, set_users) = signal(Vec::<User>::new());
|
||
|
|
let (loading, set_loading) = signal(true);
|
||
|
|
let (current_page, set_current_page) = signal(1usize);
|
||
|
|
|
||
|
|
// Fetch users (simulated)
|
||
|
|
Effect::new(move |_| {
|
||
|
|
spawn_local(async move {
|
||
|
|
// Simulate API call
|
||
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||
|
|
|
||
|
|
let mock_users = vec![
|
||
|
|
User {
|
||
|
|
id: "1".to_string(),
|
||
|
|
name: "Alice Smith".to_string(),
|
||
|
|
email: "alice@example.com".to_string(),
|
||
|
|
status: "Active".to_string(),
|
||
|
|
role: "Admin".to_string(),
|
||
|
|
},
|
||
|
|
// ... more users
|
||
|
|
];
|
||
|
|
|
||
|
|
set_users.set(mock_users);
|
||
|
|
set_loading.set(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<div class="space-y-6">
|
||
|
|
<Show
|
||
|
|
when=move || !loading.get()
|
||
|
|
fallback=|| view! {
|
||
|
|
<div class="flex flex-col items-center justify-center py-12 gap-4">
|
||
|
|
<Spinner />
|
||
|
|
<div class="text-lg text-white/80">"Loading users..."</div>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{move || {
|
||
|
|
let all_users = users.get();
|
||
|
|
let total_items = all_users.len();
|
||
|
|
let total_pages = total_items.div_ceil(ITEMS_PER_PAGE);
|
||
|
|
|
||
|
|
// Paginate
|
||
|
|
let page = current_page.get();
|
||
|
|
let start_idx = (page - 1) * ITEMS_PER_PAGE;
|
||
|
|
let end_idx = (start_idx + ITEMS_PER_PAGE).min(total_items);
|
||
|
|
|
||
|
|
// Convert to table rows
|
||
|
|
let columns = vec![
|
||
|
|
TableColumn::new("Name", "name").sortable(),
|
||
|
|
TableColumn::new("Email", "email").sortable(),
|
||
|
|
TableColumn::new("Status", "status").sortable(),
|
||
|
|
TableColumn::new("Role", "role"),
|
||
|
|
];
|
||
|
|
|
||
|
|
let rows: Vec<Vec<String>> = all_users
|
||
|
|
.into_iter()
|
||
|
|
.skip(start_idx)
|
||
|
|
.take(end_idx - start_idx)
|
||
|
|
.map(|user| {
|
||
|
|
vec![user.name, user.email, user.status, user.role]
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<div class="flex flex-col gap-6">
|
||
|
|
<Table columns=columns rows=rows />
|
||
|
|
|
||
|
|
{move || {
|
||
|
|
if total_pages > 1 {
|
||
|
|
view! {
|
||
|
|
<div class="flex justify-center">
|
||
|
|
<Pagination
|
||
|
|
current_page=current_page.get()
|
||
|
|
total_pages=total_pages
|
||
|
|
on_page_change=Callback::new(move |page| {
|
||
|
|
set_current_page.set(page);
|
||
|
|
})
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
}.into_any()
|
||
|
|
} else {
|
||
|
|
view! { <div /> }.into_any()
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
</Show>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Dashboard with Stats
|
||
|
|
|
||
|
|
Complete dashboard example with stat cards and data display:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use leptos::prelude::*;
|
||
|
|
use vapora_leptos_ui::{StatCard, Card, Badge, GlowColor, Spinner};
|
||
|
|
|
||
|
|
#[component]
|
||
|
|
fn Dashboard() -> impl IntoView {
|
||
|
|
let (loading, set_loading) = signal(true);
|
||
|
|
let (total_users, set_total_users) = signal(0);
|
||
|
|
let (active_projects, set_active_projects) = signal(0);
|
||
|
|
let (revenue, set_revenue) = signal(0);
|
||
|
|
|
||
|
|
// Fetch stats
|
||
|
|
Effect::new(move |_| {
|
||
|
|
spawn_local(async move {
|
||
|
|
// Simulate API call
|
||
|
|
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||
|
|
|
||
|
|
set_total_users.set(1234);
|
||
|
|
set_active_projects.set(42);
|
||
|
|
set_revenue.set(12345);
|
||
|
|
set_loading.set(false);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
view! {
|
||
|
|
<div class="space-y-8">
|
||
|
|
<h1 class="text-3xl font-bold text-white">"Dashboard"</h1>
|
||
|
|
|
||
|
|
<Show
|
||
|
|
when=move || !loading.get()
|
||
|
|
fallback=|| view! {
|
||
|
|
<div class="flex justify-center py-12">
|
||
|
|
<Spinner />
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
// Stats cards
|
||
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
|
|
<StatCard
|
||
|
|
label="Total Users".to_string()
|
||
|
|
value=move || total_users.get().to_string()
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
label="Active Projects".to_string()
|
||
|
|
value=move || active_projects.get().to_string()
|
||
|
|
trend_positive=true
|
||
|
|
/>
|
||
|
|
<StatCard
|
||
|
|
label="Revenue".to_string()
|
||
|
|
value=move || format!("${}", revenue.get())
|
||
|
|
trend_positive=true
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
// Recent projects
|
||
|
|
<div>
|
||
|
|
<h2 class="text-2xl font-semibold text-white mb-4">"Recent Projects"</h2>
|
||
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
|
|
<Card glow=GlowColor::Cyan hoverable=true>
|
||
|
|
<div class="flex items-center justify-between mb-2">
|
||
|
|
<h3 class="text-lg font-semibold text-cyan-400">"Project Alpha"</h3>
|
||
|
|
<Badge class="bg-green-500/20 text-green-400">"Active"</Badge>
|
||
|
|
</div>
|
||
|
|
<p class="text-gray-400 text-sm">
|
||
|
|
"Multi-agent development platform"
|
||
|
|
</p>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card glow=GlowColor::Purple hoverable=true>
|
||
|
|
<div class="flex items-center justify-between mb-2">
|
||
|
|
<h3 class="text-lg font-semibold text-purple-400">"Project Beta"</h3>
|
||
|
|
<Badge class="bg-blue-500/20 text-blue-400">"In Progress"</Badge>
|
||
|
|
</div>
|
||
|
|
<p class="text-gray-400 text-sm">
|
||
|
|
"AI-powered code reviews"
|
||
|
|
</p>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card glow=GlowColor::Pink hoverable=true>
|
||
|
|
<div class="flex items-center justify-between mb-2">
|
||
|
|
<h3 class="text-lg font-semibold text-pink-400">"Project Gamma"</h3>
|
||
|
|
<Badge class="bg-gray-500/20 text-gray-400">"Completed"</Badge>
|
||
|
|
</div>
|
||
|
|
<p class="text-gray-400 text-sm">
|
||
|
|
"Automated testing suite"
|
||
|
|
</p>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</Show>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Best Practices
|
||
|
|
|
||
|
|
### Event Handling
|
||
|
|
|
||
|
|
Always use `Callback::new()` for event handlers:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// ✅ CORRECT
|
||
|
|
<Button on_click=Some(Callback::new(move |_| { /* handler */ }))>
|
||
|
|
|
||
|
|
// ❌ WRONG (type mismatch)
|
||
|
|
<Button on_click=Some(move |_| { /* handler */ })>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Optional Props
|
||
|
|
|
||
|
|
For props marked `#[prop(optional)]` with `Option<T>`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// ✅ CORRECT - Pass T directly (gets wrapped in Some)
|
||
|
|
<FormField error="Username is required".to_string() />
|
||
|
|
|
||
|
|
// ✅ CORRECT - Omit prop entirely (becomes None)
|
||
|
|
<FormField />
|
||
|
|
|
||
|
|
// ❌ WRONG - Don't pass Option<T>
|
||
|
|
<FormField error=Some("Error".to_string()) /> // Type mismatch
|
||
|
|
```
|
||
|
|
|
||
|
|
### Conditional Rendering
|
||
|
|
|
||
|
|
Use conditional rendering for optional props with dynamic values:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
{move || {
|
||
|
|
if let Some(err) = error.get() {
|
||
|
|
view! { <FormField error=err /> }
|
||
|
|
} else {
|
||
|
|
view! { <FormField /> }
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Extract Input Values
|
||
|
|
|
||
|
|
Helper for Leptos 0.8 (no built-in `event_target_value`):
|
||
|
|
|
||
|
|
```rust
|
||
|
|
#[cfg(target_arch = "wasm32")]
|
||
|
|
fn event_target_value(ev: &leptos::ev::Event) -> String {
|
||
|
|
use wasm_bindgen::JsCast;
|
||
|
|
ev.target()
|
||
|
|
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
|
||
|
|
.map(|input| input.value())
|
||
|
|
.unwrap_or_default()
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
**Last Updated**: 2026-02-08 (v1.2.0)
|