feat(leptos): UI primitives — Input, Select, Tabs, ScrollArea, Toast
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
// services/frontend-leptos/frontend/src/ui/input.rs
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Input(
|
||||
#[prop(optional)] input_type: &'static str,
|
||||
#[prop(optional)] placeholder: &'static str,
|
||||
#[prop(optional)] value: RwSignal<String>,
|
||||
#[prop(optional)] soft: bool,
|
||||
#[prop(optional)] error: bool,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(optional)] on_input: Option<Box<dyn Fn(String)>>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<input
|
||||
type=input_type
|
||||
class={if !class.is_empty() { format!("input {}", class) } else { "input".to_string() }}
|
||||
class:input-soft=soft
|
||||
class:input-error=error
|
||||
placeholder=placeholder
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
value.set(val.clone());
|
||||
if let Some(ref cb) = on_input { cb(val); }
|
||||
}
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TextArea(
|
||||
#[prop(optional)] placeholder: &'static str,
|
||||
#[prop(optional)] value: RwSignal<String>,
|
||||
#[prop(optional)] rows: u32,
|
||||
#[prop(optional)] class: &'static str,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<textarea
|
||||
class={if !class.is_empty() { format!("input {}", class) } else { "input".to_string() }}
|
||||
placeholder=placeholder
|
||||
prop:value=move || value.get()
|
||||
on:input=move |ev| value.set(event_target_value(&ev))
|
||||
rows=rows
|
||||
></textarea>
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,8 @@
|
||||
pub mod badge;
|
||||
pub mod button;
|
||||
pub mod card;
|
||||
pub mod input;
|
||||
pub mod scroll_area;
|
||||
pub mod select;
|
||||
pub mod tabs;
|
||||
pub mod toast;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// services/frontend-leptos/frontend/src/ui/scroll_area.rs
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn ScrollArea(
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(optional)] style: &'static str,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class={if !class.is_empty() { format!("scroll-area {}", class) } else { "scroll-area".to_string() }} style=style>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// services/frontend-leptos/frontend/src/ui/select.rs
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// Simple select — values and labels are the same
|
||||
/// For options with different value/label, use `SelectOptions`
|
||||
#[component]
|
||||
pub fn Select(
|
||||
#[prop(optional)] value: RwSignal<String>,
|
||||
options: Vec<(&'static str, &'static str)>, // (value, label)
|
||||
#[prop(optional)] placeholder: &'static str,
|
||||
#[prop(optional)] class: &'static str,
|
||||
#[prop(optional)] on_change: Option<Box<dyn Fn(String)>>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<select
|
||||
class={if !class.is_empty() { format!("select {}", class) } else { "select".to_string() }}
|
||||
prop:value=move || value.get()
|
||||
on:change=move |ev| {
|
||||
let val = event_target_value(&ev);
|
||||
value.set(val.clone());
|
||||
if let Some(ref cb) = on_change { cb(val); }
|
||||
}
|
||||
>
|
||||
<option value="" disabled=placeholder.len() > 0>{placeholder}</option>
|
||||
{options.into_iter().map(|(val, label)| view! {
|
||||
<option value=val selected=move || value.get() == val>{label}</option>
|
||||
}).collect::<Vec<_>>()}
|
||||
</select>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// services/frontend-leptos/frontend/src/ui/tabs.rs
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Tabs(
|
||||
active: RwSignal<String>,
|
||||
#[prop(optional)] class: &'static str,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class={if !class.is_empty() { format!("tabs {}", class) } else { "tabs".to_string() }}>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabList(
|
||||
#[prop(optional)] class: &'static str,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class={if !class.is_empty() { format!("tab-list {}", class) } else { "tab-list".to_string() }} role="tablist">
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabTrigger(
|
||||
value: String,
|
||||
active: RwSignal<String>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
let v1 = value.clone();
|
||||
let v2 = value.clone();
|
||||
view! {
|
||||
<button
|
||||
class="tab-trigger"
|
||||
class:active=move || active.get() == v1
|
||||
role="tab"
|
||||
aria-selected=move || if active.get() == v2 { "true" } else { "false" }
|
||||
on:click=move |_| active.set(value.clone())
|
||||
>
|
||||
{children()}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TabContent(
|
||||
value: String,
|
||||
active: RwSignal<String>,
|
||||
children: Children,
|
||||
) -> impl IntoView {
|
||||
let is_selected = move || active.get() == value;
|
||||
view! {
|
||||
<div
|
||||
class="tab-content"
|
||||
role="tabpanel"
|
||||
style:display=move || if is_selected() { "block" } else { "none" }
|
||||
>
|
||||
{children()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// services/frontend-leptos/frontend/src/ui/toast.rs
|
||||
use leptos::prelude::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ToastType {
|
||||
Info,
|
||||
Success,
|
||||
Error,
|
||||
Warning,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToastMessage {
|
||||
pub id: u64,
|
||||
pub message: String,
|
||||
pub toast_type: ToastType,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToastContext {
|
||||
pub toasts: RwSignal<Vec<ToastMessage>>,
|
||||
next_id: Arc<Mutex<u64>>,
|
||||
}
|
||||
|
||||
impl ToastContext {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
toasts: create_rw_signal(vec![]),
|
||||
next_id: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(&self, message: &str, toast_type: ToastType) {
|
||||
let id = {
|
||||
let mut n = self.next_id.lock().unwrap();
|
||||
*n += 1;
|
||||
*n
|
||||
};
|
||||
let msg = ToastMessage {
|
||||
id,
|
||||
message: message.to_string(),
|
||||
toast_type,
|
||||
};
|
||||
self.toasts.update(|t| t.push(msg));
|
||||
|
||||
// Auto-dismiss after 4 seconds
|
||||
let toasts = self.toasts;
|
||||
leptos::prelude::set_timeout(
|
||||
move || {
|
||||
toasts.update(|t| t.retain(|m| m.id != id));
|
||||
},
|
||||
std::time::Duration::from_secs(4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ToastProvider(children: Children) -> impl IntoView {
|
||||
let ctx = ToastContext::new();
|
||||
provide_context(ctx.clone());
|
||||
|
||||
view! {
|
||||
{children()}
|
||||
<div class="toast-container">
|
||||
{move || ctx.toasts.get().into_iter().map(|msg| {
|
||||
let type_class = match msg.toast_type {
|
||||
ToastType::Info => "toast-info",
|
||||
ToastType::Success => "toast-success",
|
||||
ToastType::Error => "toast-error",
|
||||
ToastType::Warning => "toast-warning",
|
||||
};
|
||||
let toasts = ctx.toasts;
|
||||
view! {
|
||||
<div class={format!("toast {}", type_class)}>
|
||||
<span>{msg.message}</span>
|
||||
<button class="toast-close" on:click=move |_| {
|
||||
toasts.update(|t| t.retain(|m| m.id != msg.id));
|
||||
}>"×"</button>
|
||||
</div>
|
||||
}
|
||||
}).collect::<Vec<_>>()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user