chattyness/crates/chattyness-user-ui/src/components/forms.rs

353 lines
12 KiB
Rust

//! Form components with WCAG 2.2 AA accessibility.
use leptos::prelude::*;
/// Text input field with label.
#[component]
pub fn TextInput(
name: &'static str,
label: &'static str,
#[prop(default = "text")] input_type: &'static str,
#[prop(optional)] placeholder: &'static str,
#[prop(optional)] help_text: &'static str,
#[prop(default = false)] required: bool,
#[prop(optional)] minlength: Option<i32>,
#[prop(optional)] maxlength: Option<i32>,
#[prop(optional)] pattern: &'static str,
#[prop(optional)] autocomplete: &'static str,
#[prop(optional)] class: &'static str,
#[prop(into)] value: Signal<String>,
on_input: Callback<String>,
) -> impl IntoView {
let input_id = name;
let help_id = format!("{}-help", name);
let has_help = !help_text.is_empty();
view! {
<div class=format!("space-y-2 {}", class)>
<label for=input_id class="block text-sm font-medium text-gray-300">
{label}
{if required {
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
} else {
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
}}
</label>
<input
type=input_type
id=input_id
name=name
placeholder=placeholder
required=required
minlength=minlength
maxlength=maxlength
pattern=if pattern.is_empty() { None } else { Some(pattern) }
autocomplete=if autocomplete.is_empty() { None } else { Some(autocomplete) }
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
class="input-base"
prop:value=move || value.get()
on:input=move |ev| on_input.run(event_target_value(&ev))
/>
{if has_help {
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
} else {
view! {}.into_any()
}}
</div>
}
}
/// Textarea field with label.
#[component]
pub fn TextArea(
name: &'static str,
label: &'static str,
#[prop(optional)] placeholder: &'static str,
#[prop(optional)] help_text: &'static str,
#[prop(default = false)] required: bool,
#[prop(default = 3)] rows: i32,
#[prop(optional)] class: &'static str,
#[prop(into)] value: Signal<String>,
on_input: Callback<String>,
) -> impl IntoView {
let input_id = name;
let help_id = format!("{}-help", name);
let has_help = !help_text.is_empty();
view! {
<div class=format!("space-y-2 {}", class)>
<label for=input_id class="block text-sm font-medium text-gray-300">
{label}
{if required {
view! { <span class="text-red-400 ml-1" aria-hidden="true">"*"</span> }.into_any()
} else {
view! { <span class="text-gray-500 ml-1">"(optional)"</span> }.into_any()
}}
</label>
<textarea
id=input_id
name=name
placeholder=placeholder
required=required
rows=rows
aria-describedby=if has_help { Some(help_id.clone()) } else { None }
class="input-base resize-y"
prop:value=move || value.get()
on:input=move |ev| on_input.run(event_target_value(&ev))
/>
{if has_help {
view! { <p id=help_id class="text-sm text-gray-400">{help_text}</p> }.into_any()
} else {
view! {}.into_any()
}}
</div>
}
}
/// Radio button group.
#[component]
pub fn RadioGroup(
name: &'static str,
legend: &'static str,
options: Vec<(&'static str, &'static str, &'static str)>,
#[prop(into)] value: Signal<String>,
on_change: Callback<String>,
) -> impl IntoView {
view! {
<fieldset class="space-y-3">
<legend class="block text-sm font-medium text-gray-300 mb-2">{legend}</legend>
<div class="space-y-2">
{options
.into_iter()
.map(|(val, label, description)| {
let val_clone = val.to_string();
let is_selected = Signal::derive(move || value.get() == val);
view! {
<label class="flex items-start space-x-3 cursor-pointer group">
<input
type="radio"
name=name
value=val
checked=move || is_selected.get()
on:change=move |_| on_change.run(val_clone.clone())
class="mt-1 w-4 h-4 text-blue-500 bg-gray-700 border-gray-600 focus:ring-blue-500 focus:ring-2"
/>
<div>
<span class="text-white group-hover:text-blue-400 transition-colors">
{label}
</span>
<p class="text-sm text-gray-400">{description}</p>
</div>
</label>
}
})
.collect_view()}
</div>
</fieldset>
}
}
/// Checkbox input.
#[component]
pub fn Checkbox(
name: &'static str,
label: &'static str,
#[prop(optional)] description: &'static str,
#[prop(into)] checked: Signal<bool>,
on_change: Callback<bool>,
) -> impl IntoView {
let has_description = !description.is_empty();
view! {
<label class="flex items-start space-x-3 cursor-pointer group">
<input
type="checkbox"
name=name
prop:checked=move || checked.get()
on:change=move |ev| on_change.run(event_target_checked(&ev))
class="mt-1 w-4 h-4 text-blue-500 bg-gray-700 border-gray-600 rounded focus:ring-blue-500 focus:ring-2"
/>
<div>
<span class="text-white group-hover:text-blue-400 transition-colors">{label}</span>
{if has_description {
view! { <p class="text-sm text-gray-400">{description}</p> }.into_any()
} else {
view! {}.into_any()
}}
</div>
</label>
}
}
/// Range slider input.
#[component]
pub fn RangeSlider(
name: &'static str,
label: &'static str,
min: i32,
max: i32,
#[prop(default = 1)] step: i32,
#[prop(into)] value: Signal<i32>,
on_change: Callback<i32>,
) -> impl IntoView {
let input_id = name;
view! {
<div class="space-y-2">
<div class="flex justify-between items-center">
<label for=input_id class="block text-sm font-medium text-gray-300">
{label}
</label>
<span class="text-sm text-gray-400">{move || value.get()}</span>
</div>
<input
type="range"
id=input_id
name=name
min=min
max=max
step=step
prop:value=move || value.get()
on:input=move |ev| {
if let Ok(val) = event_target_value(&ev).parse::<i32>() {
on_change.run(val);
}
}
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
/>
<div class="flex justify-between text-xs text-gray-500">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
}
}
/// Primary submit button.
#[component]
pub fn SubmitButton(
#[prop(default = "Submit")] text: &'static str,
#[prop(default = "Submitting...")] loading_text: &'static str,
#[prop(into)] pending: Signal<bool>,
) -> impl IntoView {
view! {
<button type="submit" disabled=move || pending.get() class="btn-primary w-full">
{move || if pending.get() { loading_text } else { text }}
</button>
}
}
/// Error alert box.
#[component]
pub fn ErrorAlert(#[prop(into)] message: Signal<Option<String>>) -> impl IntoView {
view! {
<Show when=move || message.get().is_some()>
<div class="error-message" role="alert">
<p>{move || message.get().unwrap_or_default()}</p>
</div>
</Show>
}
}
/// Success alert box.
#[component]
pub fn SuccessAlert(#[prop(into)] message: Signal<Option<String>>) -> impl IntoView {
view! {
<Show when=move || message.get().is_some()>
<div
class="p-4 bg-green-900/50 border border-green-500 rounded-lg text-green-200"
role="alert"
>
<p>{move || message.get().unwrap_or_default()}</p>
</div>
</Show>
}
}
/// Color picker component.
#[component]
pub fn ColorPicker(
#[prop(into)] value: Signal<String>,
on_change: Callback<String>,
label: &'static str,
id: &'static str,
) -> impl IntoView {
view! {
<div class="flex items-center gap-3">
<label for=id class="text-sm font-medium text-gray-300">
{label}
</label>
<input
type="color"
id=id
prop:value=move || value.get()
on:input=move |ev| on_change.run(event_target_value(&ev))
class="w-10 h-10 rounded border border-gray-600 cursor-pointer"
/>
<input
type="text"
prop:value=move || value.get()
on:input=move |ev| on_change.run(event_target_value(&ev))
class="input-base w-24 text-sm"
placeholder="#1a1a2e"
/>
</div>
}
}
/// Color palette component.
#[component]
pub fn ColorPalette(
#[prop(into)] value: Signal<String>,
on_change: Callback<String>,
) -> impl IntoView {
let colors = [
"#1a1a2e", "#16213e", "#0f3460", "#e94560", "#533483", "#2c3e50", "#1e8449", "#d35400",
];
view! {
<div class="flex gap-2 flex-wrap">
{colors
.into_iter()
.map(|color| {
let is_selected = Signal::derive(move || value.get() == color);
let color_string = color.to_string();
view! {
<button
type="button"
class=move || {
let base = "w-8 h-8 rounded border-2 transition-transform hover:scale-110";
if is_selected.get() {
format!("{} border-white", base)
} else {
format!("{} border-transparent", base)
}
}
style=format!("background-color: {}", color)
title=color
on:click=move |_| on_change.run(color_string.clone())
/>
}
})
.collect_view()}
</div>
}
}
/// Get the checked state of a checkbox input.
#[cfg(feature = "hydrate")]
fn event_target_checked(ev: &leptos::ev::Event) -> bool {
use wasm_bindgen::JsCast;
ev.target()
.and_then(|t| {
t.dyn_ref::<web_sys::HtmlInputElement>()
.map(|el| el.checked())
})
.unwrap_or(false)
}
/// Stub for SSR.
#[cfg(not(feature = "hydrate"))]
fn event_target_checked(_ev: &leptos::ev::Event) -> bool {
false
}