#![doc = include_str!("../YEW.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::yew::Input;
use yew::prelude::*;
#[derive(Clone, Debug, PartialEq)]
pub struct FormContext {
pub validation_behavior: ValidationBehavior,
pub is_submitting: bool,
pub novalidate: bool,
}
#[derive(Clone, Debug, PartialEq)]
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,
pub input_id: String,
}
#[derive(Properties, PartialEq, Clone)]
pub struct FormProps {
#[prop_or_default]
pub children: Children,
#[prop_or_default]
pub class: &'static str,
#[prop_or_default]
pub style: &'static str,
#[prop_or_default]
pub id: &'static str,
#[prop_or_default]
pub action: &'static str,
#[prop_or_default]
pub method: Method,
#[prop_or_default]
pub enc_type: EncType,
#[prop_or_default]
pub target: Target,
#[prop_or_default]
pub novalidate: bool,
#[prop_or("on")]
pub autocomplete: &'static str,
#[prop_or_default]
pub name: &'static str,
#[prop_or_default]
pub validation_behavior: ValidationBehavior,
#[prop_or_default]
pub on_submit: Callback<SubmitEvent>,
#[prop_or_default]
pub on_reset: Callback<Event>,
#[prop_or_default]
pub on_invalid: Callback<Event>,
#[prop_or_default]
pub aria_label: &'static str,
#[prop_or_default]
pub aria_labelledby: &'static str,
#[prop_or_default]
pub data_testid: &'static str,
}
#[function_component(Form)]
pub fn form(props: &FormProps) -> Html {
let is_submitting = use_state(|| false);
let ctx = FormContext {
validation_behavior: props.validation_behavior,
is_submitting: *is_submitting,
novalidate: props.novalidate || props.validation_behavior == ValidationBehavior::Aria,
};
let on_submit = {
let user_cb = props.on_submit.clone();
let is_submitting = is_submitting.clone();
Callback::from(move |e: SubmitEvent| {
is_submitting.set(true);
user_cb.emit(e);
is_submitting.set(false);
})
};
let on_reset = props.on_reset.clone();
let on_invalid = props.on_invalid.clone();
let novalidate = props.novalidate || props.validation_behavior == ValidationBehavior::Aria;
let full_style = format!("{} {}", base_form_style(), props.style);
html! {
<ContextProvider<FormContext> context={ctx}>
<form
id={props.id}
class={format!("form {}", props.class)}
style={full_style}
action={props.action}
method={props.method.as_str()}
enctype={props.enc_type.as_str()}
target={props.target.as_str()}
novalidate={novalidate}
autocomplete={props.autocomplete}
name={props.name}
onsubmit={on_submit}
onreset={Callback::from(move |e: Event| on_reset.emit(e))}
oninvalid={Callback::from(move |e: Event| on_invalid.emit(e))}
aria-label={props.aria_label}
aria-labelledby={props.aria_labelledby}
data-testid={props.data_testid}
>
{ for props.children.iter() }
</form>
</ContextProvider<FormContext>>
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct ControlProps {
#[prop_or_default]
pub children: Children,
#[prop_or_default]
pub class: &'static str,
#[prop_or_default]
pub style: &'static str,
#[prop_or_default]
pub id: &'static str,
#[prop_or_default]
pub input_id: &'static str,
#[prop_or_default]
pub disabled: bool,
#[prop_or_default]
pub error: bool,
#[prop_or_default]
pub focused: bool,
#[prop_or_default]
pub full_width: bool,
#[prop_or_default]
pub hidden_label: bool,
#[prop_or_default]
pub margin: Margin,
#[prop_or_default]
pub required: bool,
#[prop_or_default]
pub size: Size,
#[prop_or_default]
pub variant: Variant,
#[prop_or_default]
pub color: Color,
#[prop_or_default]
pub data_testid: &'static str,
}
#[function_component(Control)]
pub fn form_control(props: &ControlProps) -> Html {
let ctx = ControlContext {
error: props.error,
disabled: props.disabled,
focused: props.focused,
required: props.required,
filled: false,
variant: props.variant,
color: props.color,
size: props.size,
input_id: props.input_id.to_string(),
};
let mut class_parts = vec!["form-control".to_string()];
class_parts.push(props.variant.to_class().to_string());
class_parts.push(props.size.to_class().to_string());
if !props.margin.to_class().is_empty() {
class_parts.push(props.margin.to_class().to_string());
}
if props.full_width {
class_parts.push("form-control--full-width".to_string());
}
if props.error {
class_parts.push("form-control--error".to_string());
}
if props.disabled {
class_parts.push("form-control--disabled".to_string());
}
if props.focused {
class_parts.push("form-control--focused".to_string());
}
if props.hidden_label {
class_parts.push("form-control--hidden-label".to_string());
}
class_parts.push(props.class.to_string());
let mut style_parts = vec![base_form_control_style().to_string()];
style_parts.push(props.margin.to_style().to_string());
if props.full_width {
style_parts.push("width: 100%;".to_string());
}
if props.disabled {
style_parts.push(field_disabled_style().to_string());
}
style_parts.push(props.style.to_string());
html! {
<ContextProvider<ControlContext> context={ctx}>
<div
id={props.id}
class={class_parts.join(" ")}
style={style_parts.join(" ")}
data-testid={props.data_testid}
>
{ for props.children.iter() }
</div>
</ContextProvider<ControlContext>>
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct FormLabelProps {
#[prop_or_default]
pub children: Children,
#[prop_or_default]
pub class: &'static str,
#[prop_or_default]
pub style: &'static str,
#[prop_or_default]
pub id: &'static str,
#[prop_or_default]
pub html_for: &'static str,
#[prop_or_default]
pub color: Option<Color>,
#[prop_or_default]
pub disabled: bool,
#[prop_or_default]
pub error: bool,
#[prop_or_default]
pub filled: bool,
#[prop_or_default]
pub focused: bool,
#[prop_or_default]
pub required: bool,
#[prop_or_default]
pub data_testid: &'static str,
}
#[function_component(FormLabel)]
pub fn form_label(props: &FormLabelProps) -> Html {
let ctx = use_context::<ControlContext>();
let error = props.error || ctx.as_ref().is_some_and(|c| c.error);
let disabled = props.disabled || ctx.as_ref().is_some_and(|c| c.disabled);
let focused = props.focused || ctx.as_ref().is_some_and(|c| c.focused);
let required = props.required || ctx.as_ref().is_some_and(|c| c.required);
let color = props
.color
.or_else(|| ctx.as_ref().map(|c| c.color))
.unwrap_or_default();
let linked_id = if !props.html_for.is_empty() {
props.html_for.to_string()
} else {
ctx.as_ref().map(|c| c.input_id.clone()).unwrap_or_default()
};
let color_style = if error {
label_error_style().to_string()
} else if focused {
color.to_label_color()
} else {
String::new()
};
let mut class_parts = vec!["form-label".to_string()];
if error {
class_parts.push("form-label--error".to_string());
}
if disabled {
class_parts.push("form-label--disabled".to_string());
}
if focused {
class_parts.push("form-label--focused".to_string());
}
if props.filled {
class_parts.push("form-label--filled".to_string());
}
class_parts.push(props.class.to_string());
let full_style = format!("{} {} {}", base_label_style(), color_style, props.style);
html! {
<label
id={props.id}
class={class_parts.join(" ")}
style={full_style}
for={linked_id}
aria-disabled={if disabled { "true" } else { "false" }}
data-testid={props.data_testid}
>
{ for props.children.iter() }
if required {
<span style={required_asterisk_style()} aria-hidden="true">{ "*" }</span>
}
</label>
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct HelperProps {
#[prop_or_default]
pub children: Children,
#[prop_or_default]
pub class: &'static str,
#[prop_or_default]
pub style: &'static str,
#[prop_or_default]
pub id: &'static str,
#[prop_or_default]
pub disabled: bool,
#[prop_or_default]
pub error: bool,
#[prop_or_default]
pub valid: bool,
#[prop_or_default]
pub filled: bool,
#[prop_or_default]
pub focused: bool,
#[prop_or_default]
pub margin: Margin,
#[prop_or_default]
pub required: bool,
#[prop_or_default]
pub data_testid: &'static str,
}
#[function_component(Helper)]
pub fn form_helper_text(props: &HelperProps) -> Html {
let ctx = use_context::<ControlContext>();
let error = props.error || ctx.as_ref().is_some_and(|c| c.error);
let disabled = props.disabled || ctx.as_ref().is_some_and(|c| c.disabled);
let color_style = if error {
helper_error_style()
} else if props.valid {
helper_valid_style()
} else {
""
};
let mut class_parts = vec!["form-helper-text".to_string()];
if error {
class_parts.push("form-helper-text--error".to_string());
}
if props.valid {
class_parts.push("form-helper-text--valid".to_string());
}
if disabled {
class_parts.push("form-helper-text--disabled".to_string());
}
if props.focused {
class_parts.push("form-helper-text--focused".to_string());
}
if props.filled {
class_parts.push("form-helper-text--filled".to_string());
}
if !props.margin.to_class().is_empty() {
class_parts.push(props.margin.to_class().to_string());
}
class_parts.push(props.class.to_string());
let full_style = format!(
"{} {} {}",
base_helper_text_style(),
color_style,
props.style
);
html! {
<p
id={props.id}
class={class_parts.join(" ")}
style={full_style}
role={if error { "alert" } else { "" }}
aria-live={if error { "polite" } else { "" }}
aria-disabled={if disabled { "true" } else { "false" }}
data-testid={props.data_testid}
>
{ for props.children.iter() }
</p>
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct GroupProps {
#[prop_or_default]
pub children: Children,
#[prop_or_default]
pub class: &'static str,
#[prop_or_default]
pub style: &'static str,
#[prop_or_default]
pub id: &'static str,
#[prop_or_default]
pub row: bool,
#[prop_or_default]
pub error: bool,
#[prop_or_default]
pub aria_label: &'static str,
#[prop_or_default]
pub aria_labelledby: &'static str,
#[prop_or_default]
pub data_testid: &'static str,
}
#[function_component(Group)]
pub fn form_group(props: &GroupProps) -> Html {
let base_style = if props.row {
base_form_group_row_style()
} else {
base_form_group_style()
};
let mut class_parts = vec!["form-group".to_string()];
if props.row {
class_parts.push("form-group--row".to_string());
}
if props.error {
class_parts.push("form-group--error".to_string());
}
class_parts.push(props.class.to_string());
let full_style = format!("{} {}", base_style, props.style);
html! {
<div
id={props.id}
class={class_parts.join(" ")}
style={full_style}
role="group"
aria-label={props.aria_label}
aria-labelledby={props.aria_labelledby}
data-testid={props.data_testid}
>
{ for props.children.iter() }
</div>
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct ControlLabelProps {
pub control: Html,
#[prop_or_default]
pub label: Html,
#[prop_or_default]
pub class: &'static str,
#[prop_or_default]
pub style: &'static str,
#[prop_or_default]
pub id: &'static str,
#[prop_or_default]
pub checked: bool,
#[prop_or_default]
pub disabled: bool,
#[prop_or_default]
pub label_placement: LabelPlacement,
#[prop_or_default]
pub required: bool,
#[prop_or_default]
pub value: &'static str,
#[prop_or_default]
pub data_testid: &'static str,
}
#[function_component(ControlLabel)]
pub fn form_control_label(props: &ControlLabelProps) -> Html {
let flex_style = props.label_placement.to_flex_direction();
let placement_class = props.label_placement.to_class();
let mut class_parts = vec!["form-control-label".to_string()];
class_parts.push(placement_class.to_string());
if props.disabled {
class_parts.push("form-control-label--disabled".to_string());
}
class_parts.push(props.class.to_string());
let base_style = format!(
"display: inline-flex; {} gap: 8px; align-items: center; cursor: {}; user-select: none;",
flex_style,
if props.disabled {
"not-allowed"
} else {
"pointer"
}
);
let full_style = format!("{} {}", base_style, props.style);
html! {
<label
id={props.id}
class={class_parts.join(" ")}
style={full_style}
aria-disabled={if props.disabled { "true" } else { "false" }}
aria-required={if props.required { "true" } else { "false" }}
data-testid={props.data_testid}
>
{ props.control.clone() }
<span
class="form-control-label__label"
style="font-size: 14px; color: #d4d4d8; line-height: 1.4;"
>
{ props.label.clone() }
if props.required {
<span style={required_asterisk_style()} aria-hidden="true">{ "*" }</span>
}
</span>
</label>
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct FieldProps {
pub id: &'static str,
#[prop_or_default]
pub name: &'static str,
#[prop_or("text")]
pub r#type: &'static str,
#[prop_or_default]
pub label: &'static str,
#[prop_or_default]
pub placeholder: &'static str,
#[prop_or_default]
pub helper_text: &'static str,
#[prop_or_default]
pub validation_state: ValidationState,
#[prop_or_default]
pub required: bool,
#[prop_or_default]
pub disabled: bool,
#[prop_or(true)]
pub full_width: bool,
#[prop_or_default]
pub variant: Variant,
#[prop_or_default]
pub color: Color,
#[prop_or_default]
pub size: Size,
#[prop_or(".*")]
pub pattern: &'static str,
#[prop_or_default]
pub maxlength: Option<usize>,
#[prop_or_default]
pub minlength: Option<usize>,
#[prop_or_default]
pub class: &'static str,
#[prop_or_default]
pub style: &'static str,
pub handle: UseStateHandle<String>,
pub valid_handle: UseStateHandle<bool>,
pub validate_function: Callback<String, bool>,
#[prop_or_default]
pub data_testid: &'static str,
}
#[function_component(Field)]
pub fn form_field(props: &FieldProps) -> Html {
let is_error = props.validation_state.is_invalid()
|| (!(*props.valid_handle) && !(*props.handle).is_empty());
let is_valid = matches!(props.validation_state, ValidationState::Valid)
|| ((*props.valid_handle) && !(*props.handle).is_empty());
let error_msg = props
.validation_state
.error_message()
.map(|s| s.to_string());
let helper_id: &'static str = Box::leak(format!("{}-helper", props.id).into_boxed_str());
let focused = use_state(|| false);
let on_focus = {
let focused = focused.clone();
Callback::from(move |_| focused.set(true))
};
let on_blur = {
let focused = focused.clone();
Callback::from(move |_| focused.set(false))
};
let field_border = props.variant.to_field_style();
let input_size = props.size.to_input_style();
let focus_ring = if *focused && !is_error {
props.color.to_focus_ring()
} else {
String::new()
};
let error_ring = if is_error {
field_error_style().to_string()
} else {
String::new()
};
let valid_ring = if is_valid && !is_error {
field_valid_style().to_string()
} else {
String::new()
};
let input_style = format!(
"{} {} {} {} {} {} transition: all 0.2s ease;",
base_input_field_style(),
field_border,
input_size,
focus_ring,
error_ring,
valid_ring
);
let input_style_static: &'static str = Box::leak(input_style.into_boxed_str());
let helper_content = if let Some(ref msg) = error_msg {
msg.clone()
} else if is_error {
"Invalid value.".to_string()
} else if !props.helper_text.is_empty() {
props.helper_text.to_string()
} else {
String::new()
};
let node_ref = use_node_ref();
let validate_cb = props.validate_function.clone();
html! {
<Control
input_id={props.id}
error={is_error}
disabled={props.disabled}
focused={*focused}
full_width={props.full_width}
required={props.required}
variant={props.variant}
color={props.color}
size={props.size}
class={props.class}
style={props.style}
data_testid={props.data_testid}
>
if !props.label.is_empty() {
<FormLabel
html_for={props.id}
error={is_error}
focused={*focused}
required={props.required}
disabled={props.disabled}
>
{ html!{props.label} }
</FormLabel>
}
<Input
r#type={props.r#type}
id={props.id}
name={props.name}
placeholder={props.placeholder}
r#ref={node_ref}
handle={props.handle.clone()}
valid_handle={props.valid_handle.clone()}
validate_function={validate_cb}
required={props.required}
disabled={props.disabled}
pattern={props.pattern}
maxlength={props.maxlength}
minlength={props.minlength}
input_style={input_style_static}
aria_describedby={helper_id}
aria_required={if props.required { "true" } else { "false" }}
aria_invalid={if is_error { "true" } else { "false" }}
otp_mode=true
on_focus={on_focus}
on_blur={on_blur}
/>
if !helper_content.is_empty() {
<Helper id={helper_id} error={is_error} valid={is_valid && !is_error}>
{ html!{helper_content} }
</Helper>
}
</Control>
}
}