add initial crates and apps
This commit is contained in:
parent
5c87ba3519
commit
1ca300098f
113 changed files with 28169 additions and 0 deletions
345
crates/chattyness-user-ui/src/components/forms.rs
Normal file
345
crates/chattyness-user-ui/src/components/forms.rs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
//! 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)] 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) }
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue