#![doc = include_str!("../LEPTOS.md")]
use crate::common::{
Color, EncType, LabelPlacement, Margin, Method, Size, Target, ValidationBehavior,
ValidationState, Variant, base_form_control_style, base_form_group_row_style,
base_form_group_style, base_form_style, base_helper_text_style, base_input_field_style,
base_label_style, field_disabled_style, field_error_style, field_valid_style,
helper_error_style, helper_valid_style, label_error_style, required_asterisk_style,
};
pub use input_rs::leptos::Input;
use leptos::ev::{Event, FocusEvent, SubmitEvent};
use leptos::prelude::*;
#[derive(Clone, Debug, PartialEq, Copy)]
pub struct FormContext {
pub validation_behavior: ValidationBehavior,
pub is_submitting: bool,
pub novalidate: bool,
}
#[derive(Clone, Debug, PartialEq, Copy)]
pub struct ControlContext {
pub error: bool,
pub disabled: bool,
pub focused: bool,
pub required: bool,
pub filled: bool,
pub variant: Variant,
pub color: Color,
pub size: Size,
}
#[allow(non_snake_case)]
#[component]
pub fn Form(
children: Children,
#[prop(default = "")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
#[prop(default = "")]
id: &'static str,
#[prop(default = "")]
action: &'static str,
#[prop(default = Method::Get)]
method: Method,
#[prop(default = EncType::UrlEncoded)]
enc_type: EncType,
#[prop(default = Target::Self_)]
target: Target,
#[prop(default = false)]
novalidate: bool,
#[prop(default = "on")]
autocomplete: &'static str,
#[prop(default = "")]
name: &'static str,
#[prop(default = ValidationBehavior::Native)]
validation_behavior: ValidationBehavior,
#[prop(optional)]
on_submit: Option<Callback<SubmitEvent>>,
#[prop(optional)]
on_reset: Option<Callback<Event>>,
#[prop(default = "")]
aria_label: &'static str,
#[prop(default = "")]
aria_labelledby: &'static str,
#[prop(default = "")]
data_testid: &'static str,
) -> impl IntoView {
let is_submitting = RwSignal::new(false);
let should_novalidate = novalidate || validation_behavior == ValidationBehavior::Aria;
let ctx = FormContext {
validation_behavior,
is_submitting: is_submitting.get(),
novalidate: should_novalidate,
};
provide_context(ctx);
let full_style = format!("{} {}", base_form_style(), style);
let full_class = format!("form {}", class);
view! {
<form
id=id
class=full_class
style=full_style
action=action
method=method.as_str()
enctype=enc_type.as_str()
target=target.as_str()
novalidate=should_novalidate
autocomplete=autocomplete
name=name
on:submit=move |e| {
is_submitting.set(true);
if let Some(cb) = on_submit { cb.run(e); }
is_submitting.set(false);
}
on:reset=move |e: Event| {
if let Some(cb) = on_reset { cb.run(e); }
}
aria-label=aria_label
aria-labelledby=aria_labelledby
data-testid=data_testid
>
{children()}
</form>
}
}
#[allow(non_snake_case)]
#[component]
pub fn Control(
children: Children,
#[prop(default = "")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
#[prop(default = "")]
id: &'static str,
#[allow(unused)]
#[prop(default = "")]
input_id: &'static str,
#[prop(default = false)]
disabled: bool,
#[prop(default = false)]
error: bool,
#[prop(default = false)]
focused: bool,
#[prop(default = false)]
full_width: bool,
#[prop(default = false)]
hidden_label: bool,
#[prop(default = Margin::None)]
margin: Margin,
#[prop(default = false)]
required: bool,
#[prop(default = Size::Medium)]
size: Size,
#[prop(default = Variant::Outlined)]
variant: Variant,
#[prop(default = Color::Primary)]
color: Color,
#[prop(default = "")]
data_testid: &'static str,
) -> impl IntoView {
let ctx = ControlContext {
error,
disabled,
focused,
required,
filled: false,
variant,
color,
size,
};
provide_context(ctx);
let mut cls_parts = vec![
"form-control".to_string(),
variant.to_class().to_string(),
size.to_class().to_string(),
];
if !margin.to_class().is_empty() {
cls_parts.push(margin.to_class().to_string());
}
if full_width {
cls_parts.push("form-control--full-width".to_string());
}
if error {
cls_parts.push("form-control--error".to_string());
}
if disabled {
cls_parts.push("form-control--disabled".to_string());
}
if focused {
cls_parts.push("form-control--focused".to_string());
}
if hidden_label {
cls_parts.push("form-control--hidden-label".to_string());
}
cls_parts.push(class.to_string());
let mut sty_parts = vec![
base_form_control_style().to_string(),
margin.to_style().to_string(),
];
if full_width {
sty_parts.push("width: 100%;".to_string());
}
if disabled {
sty_parts.push(field_disabled_style().to_string());
}
sty_parts.push(style.to_string());
let full_class = cls_parts.join(" ");
let full_style = sty_parts.join(" ");
view! {
<div
id=id
class=full_class
style=full_style
data-testid=data_testid
>
{children()}
</div>
}
}
#[allow(non_snake_case)]
#[component]
pub fn FormLabel(
children: Children,
#[prop(default = "")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
#[prop(default = "")]
id: &'static str,
#[prop(default = "")]
html_for: &'static str,
#[prop(optional)]
color: Option<Color>,
#[prop(default = false)]
disabled: bool,
#[prop(default = false)]
error: bool,
#[prop(default = false)]
filled: bool,
#[prop(default = false)]
focused: bool,
#[prop(default = false)]
required: bool,
#[prop(default = "")]
data_testid: &'static str,
) -> impl IntoView {
let ctx = use_context::<ControlContext>();
let is_error = error || ctx.is_some_and(|c| c.error);
let is_disabled = disabled || ctx.is_some_and(|c| c.disabled);
let is_focused = focused || ctx.is_some_and(|c| c.focused);
let is_required = required || ctx.is_some_and(|c| c.required);
let eff_color = color.or_else(|| ctx.map(|c| c.color)).unwrap_or_default();
let for_attr = if !html_for.is_empty() {
html_for.to_string()
} else {
String::new()
};
let color_style = if is_error {
label_error_style().to_string()
} else if is_focused {
eff_color.to_label_color()
} else {
String::new()
};
let mut cls_parts = vec!["form-label".to_string()];
if is_error {
cls_parts.push("form-label--error".to_string());
}
if is_disabled {
cls_parts.push("form-label--disabled".to_string());
}
if is_focused {
cls_parts.push("form-label--focused".to_string());
}
if filled {
cls_parts.push("form-label--filled".to_string());
}
cls_parts.push(class.to_string());
let full_style = format!("{} {} {}", base_label_style(), color_style, style);
let full_class = cls_parts.join(" ");
view! {
<label
id=id
class=full_class
style=full_style
for=for_attr
aria-disabled=if is_disabled { "true" } else { "false" }
data-testid=data_testid
>
{children()}
<Show when=move || is_required>
<span style=required_asterisk_style() aria-hidden="true">{"*"}</span>
</Show>
</label>
}
}
#[allow(non_snake_case)]
#[component]
pub fn Helper(
children: Children,
#[prop(default = "")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
#[prop(default = "")]
id: &'static str,
#[prop(default = false)]
disabled: bool,
#[prop(default = false)]
error: bool,
#[prop(default = false)]
valid: bool,
#[prop(default = false)]
filled: bool,
#[prop(default = false)]
focused: bool,
#[prop(default = Margin::None)]
margin: Margin,
#[prop(default = "")]
data_testid: &'static str,
) -> impl IntoView {
let ctx = use_context::<ControlContext>();
let is_error = error || ctx.is_some_and(|c| c.error);
let is_disabled = disabled || ctx.is_some_and(|c| c.disabled);
let color_style = if is_error {
helper_error_style()
} else if valid {
helper_valid_style()
} else {
""
};
let mut cls_parts = vec!["form-helper-text".to_string()];
if is_error {
cls_parts.push("form-helper-text--error".to_string());
}
if valid {
cls_parts.push("form-helper-text--valid".to_string());
}
if is_disabled {
cls_parts.push("form-helper-text--disabled".to_string());
}
if focused {
cls_parts.push("form-helper-text--focused".to_string());
}
if filled {
cls_parts.push("form-helper-text--filled".to_string());
}
if !margin.to_class().is_empty() {
cls_parts.push(margin.to_class().to_string());
}
cls_parts.push(class.to_string());
let full_style = format!("{} {} {}", base_helper_text_style(), color_style, style);
let full_class = cls_parts.join(" ");
view! {
<p
id=id
class=full_class
style=full_style
role=if is_error { "alert" } else { "" }
aria-live=if is_error { "polite" } else { "" }
aria-disabled=if is_disabled { "true" } else { "false" }
data-testid=data_testid
>
{children()}
</p>
}
}
#[allow(non_snake_case)]
#[component]
pub fn Group(
children: Children,
#[prop(default = "")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
#[prop(default = "")]
id: &'static str,
#[prop(default = false)]
row: bool,
#[prop(default = false)]
error: bool,
#[prop(default = "")]
aria_label: &'static str,
#[prop(default = "")]
aria_labelledby: &'static str,
#[prop(default = "")]
data_testid: &'static str,
) -> impl IntoView {
let base_style = if row {
base_form_group_row_style()
} else {
base_form_group_style()
};
let mut cls_parts = vec!["form-group".to_string()];
if row {
cls_parts.push("form-group--row".to_string());
}
if error {
cls_parts.push("form-group--error".to_string());
}
cls_parts.push(class.to_string());
let full_style = format!("{} {}", base_style, style);
let full_class = cls_parts.join(" ");
view! {
<div
id=id
class=full_class
style=full_style
role="group"
aria-label=aria_label
aria-labelledby=aria_labelledby
data-testid=data_testid
>
{children()}
</div>
}
}
#[allow(non_snake_case)]
#[component]
pub fn ControlLabel(
control: AnyView,
#[prop(optional)]
label: Option<AnyView>,
#[prop(default = "")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
#[prop(default = "")]
id: &'static str,
#[prop(default = false)]
checked: bool,
#[prop(default = false)]
disabled: bool,
#[prop(default = LabelPlacement::End)]
label_placement: LabelPlacement,
#[prop(default = false)]
required: bool,
#[prop(default = "")]
value: &'static str,
#[prop(default = "")]
data_testid: &'static str,
) -> impl IntoView {
let flex_style = label_placement.to_flex_direction();
let placement_class = label_placement.to_class();
let mut cls_parts = vec![
"form-control-label".to_string(),
placement_class.to_string(),
];
if disabled {
cls_parts.push("form-control-label--disabled".to_string());
}
cls_parts.push(class.to_string());
let base_style = format!(
"display: inline-flex; {} gap: 8px; align-items: center; cursor: {}; user-select: none; {}",
flex_style,
if disabled { "not-allowed" } else { "pointer" },
style
);
let full_class = cls_parts.join(" ");
let _ = checked;
let _ = value;
view! {
<label
id=id
class=full_class
style=base_style
aria-disabled=if disabled { "true" } else { "false" }
aria-required=if required { "true" } else { "false" }
data-testid=data_testid
>
{control}
<span
class="form-control-label__label"
style="font-size: 14px; color: #d4d4d8; line-height: 1.4;"
>
{label}
<Show when=move || required>
<span style=required_asterisk_style() aria-hidden="true">{"*"}</span>
</Show>
</span>
</label>
}
}
#[allow(non_snake_case)]
#[component]
pub fn Field(
id: &'static str,
#[prop(default = "")]
name: &'static str,
#[prop(default = "text")]
r#type: &'static str,
#[prop(default = "")]
label: &'static str,
#[prop(default = "")]
placeholder: &'static str,
#[prop(default = "")]
helper_text: &'static str,
#[prop(default = ValidationState::None)]
validation_state: ValidationState,
#[prop(default = false)]
required: bool,
#[prop(default = false)]
disabled: bool,
#[prop(default = true)]
full_width: bool,
#[prop(default = Variant::Outlined)]
variant: Variant,
#[prop(default = Color::Primary)]
color: Color,
#[prop(default = Size::Medium)]
size: Size,
#[prop(default = ".*")]
pattern: &'static str,
#[prop(optional)]
maxlength: Option<usize>,
#[prop(optional)]
minlength: Option<usize>,
#[prop(default = "")]
class: &'static str,
#[prop(default = "")]
style: &'static str,
handle: RwSignal<String>,
valid_handle: RwSignal<bool>,
validate_function: fn(String) -> bool,
#[prop(default = "")]
data_testid: &'static str,
) -> impl IntoView {
let focused = RwSignal::new(false);
let vs1 = validation_state.clone();
let is_error =
Memo::new(move |_| vs1.is_invalid() || (!valid_handle.get() && !handle.get().is_empty()));
let vs2 = validation_state.clone();
let is_valid = Memo::new(move |_| {
matches!(vs2, ValidationState::Valid) || (valid_handle.get() && !handle.get().is_empty())
});
let error_msg = validation_state.error_message().map(|s| s.to_string());
let helper_id = format!("{}-helper", id);
let input_style = move || {
let focus_ring = if focused.get() && !is_error.get() {
color.to_focus_ring()
} else {
String::new()
};
let error_ring = if is_error.get() {
field_error_style().to_string()
} else {
String::new()
};
let valid_ring = if is_valid.get() && !is_error.get() {
field_valid_style().to_string()
} else {
String::new()
};
format!(
"{} {} {} {} {} {} transition: all 0.2s ease;",
base_input_field_style(),
variant.to_field_style(),
size.to_input_style(),
focus_ring,
error_ring,
valid_ring
)
};
let has_helper = !helper_text.is_empty() || error_msg.is_some();
let input_style_static: &'static str = Box::leak(input_style().into_boxed_str());
let helper_id_static: &'static str = Box::leak(helper_id.clone().into_boxed_str());
let (handle_read, handle_write) = handle.split();
let (valid_read, valid_write) = valid_handle.split();
view! {
<Control
id=""
input_id=id
error=is_error.get()
disabled=disabled
focused=focused.get()
full_width=full_width
required=required
variant=variant
color=color
size=size
class=class
style=style
data_testid=data_testid
>
<Show when=move || !label.is_empty()>
<FormLabel
html_for=id
error=is_error.get()
focused=focused.get()
required=required
disabled=disabled
>
{label}
</FormLabel>
</Show>
<Input
r#type=r#type
id=id
name=name
placeholder=placeholder
handle=(handle_read, handle_write)
valid_handle=(valid_read, valid_write)
validate_function=validate_function
required=required
disabled=disabled
pattern=pattern
maxlength=maxlength
minlength=minlength
input_style=input_style_static
aria_describedby=helper_id_static
aria_required=if required { "true" } else { "false" }
aria_invalid=if is_error.get() { "true" } else { "false" }
otp_mode=true
on_focus=Callback::new(move |_: FocusEvent| focused.set(true))
on_blur=Callback::new(move |_: FocusEvent| focused.set(false))
/>
<Show when=move || has_helper>
{
let content = if let Some(ref msg) = error_msg {
msg.clone()
} else if is_error.get() {
"Invalid value.".to_string()
} else {
helper_text.to_string()
};
view! {
<Helper
id=helper_id_static
error=is_error.get()
valid=is_valid.get() && !is_error.get()
>
{content}
</Helper>
}
}
</Show>
</Control>
}
}