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 badge;
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod card;
|
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