353 lines
12 KiB
Rust
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
|
|
}
|