feat(leptos): UI primitives — Input, Select, Tabs, ScrollArea, Toast

This commit is contained in:
asepharyana
2026-07-03 17:47:21 +07:00
parent 2b514142e6
commit b9e3a255bb
6 changed files with 248 additions and 0 deletions
@@ -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>
}
}