1#![doc = include_str!("../LEPTOS.md")]
9
10use crate::common::{
11 Color, EncType, LabelPlacement, Margin, Method, Size, Target, ValidationBehavior,
12 ValidationState, Variant, base_form_control_style, base_form_group_row_style,
13 base_form_group_style, base_form_style, base_helper_text_style, base_input_field_style,
14 base_label_style, field_disabled_style, field_error_style, field_valid_style,
15 helper_error_style, helper_valid_style, label_error_style, required_asterisk_style,
16};
17pub use input_rs::leptos::Input;
18use leptos::ev::{Event, FocusEvent, SubmitEvent};
19use leptos::prelude::*;
20
21#[derive(Clone, Debug, PartialEq, Copy)]
23pub struct FormContext {
24 pub validation_behavior: ValidationBehavior,
26 pub is_submitting: bool,
28 pub novalidate: bool,
30}
31
32#[derive(Clone, Debug, PartialEq, Copy)]
34pub struct ControlContext {
35 pub error: bool,
37 pub disabled: bool,
39 pub focused: bool,
41 pub required: bool,
43 pub filled: bool,
45 pub variant: Variant,
47 pub color: Color,
49 pub size: Size,
51}
52
53#[allow(non_snake_case)]
83#[component]
84pub fn Form(
85 children: Children,
87 #[prop(default = "")]
89 class: &'static str,
90 #[prop(default = "")]
92 style: &'static str,
93 #[prop(default = "")]
95 id: &'static str,
96 #[prop(default = "")]
98 action: &'static str,
99 #[prop(default = Method::Get)]
101 method: Method,
102 #[prop(default = EncType::UrlEncoded)]
104 enc_type: EncType,
105 #[prop(default = Target::Self_)]
107 target: Target,
108 #[prop(default = false)]
110 novalidate: bool,
111 #[prop(default = "on")]
113 autocomplete: &'static str,
114 #[prop(default = "")]
116 name: &'static str,
117 #[prop(default = ValidationBehavior::Native)]
119 validation_behavior: ValidationBehavior,
120 #[prop(optional)]
122 on_submit: Option<Callback<SubmitEvent>>,
123 #[prop(optional)]
125 on_reset: Option<Callback<Event>>,
126 #[prop(default = "")]
128 aria_label: &'static str,
129 #[prop(default = "")]
131 aria_labelledby: &'static str,
132 #[prop(default = "")]
134 data_testid: &'static str,
135) -> impl IntoView {
136 let is_submitting = RwSignal::new(false);
137 let should_novalidate = novalidate || validation_behavior == ValidationBehavior::Aria;
138
139 let ctx = FormContext {
140 validation_behavior,
141 is_submitting: is_submitting.get(),
142 novalidate: should_novalidate,
143 };
144 provide_context(ctx);
145
146 let full_style = format!("{} {}", base_form_style(), style);
147 let full_class = format!("form {}", class);
148
149 view! {
150 <form
151 id=id
152 class=full_class
153 style=full_style
154 action=action
155 method=method.as_str()
156 enctype=enc_type.as_str()
157 target=target.as_str()
158 novalidate=should_novalidate
159 autocomplete=autocomplete
160 name=name
161 on:submit=move |e| {
162 is_submitting.set(true);
163 if let Some(cb) = on_submit { cb.run(e); }
164 is_submitting.set(false);
165 }
166 on:reset=move |e: Event| {
167 if let Some(cb) = on_reset { cb.run(e); }
168 }
169 aria-label=aria_label
170 aria-labelledby=aria_labelledby
171 data-testid=data_testid
172 >
173 {children()}
174 </form>
175 }
176}
177
178#[allow(non_snake_case)]
202#[component]
203pub fn Control(
204 children: Children,
206 #[prop(default = "")]
208 class: &'static str,
209 #[prop(default = "")]
211 style: &'static str,
212 #[prop(default = "")]
214 id: &'static str,
215 #[allow(unused)]
217 #[prop(default = "")]
218 input_id: &'static str,
219 #[prop(default = false)]
221 disabled: bool,
222 #[prop(default = false)]
224 error: bool,
225 #[prop(default = false)]
227 focused: bool,
228 #[prop(default = false)]
230 full_width: bool,
231 #[prop(default = false)]
233 hidden_label: bool,
234 #[prop(default = Margin::None)]
236 margin: Margin,
237 #[prop(default = false)]
239 required: bool,
240 #[prop(default = Size::Medium)]
242 size: Size,
243 #[prop(default = Variant::Outlined)]
245 variant: Variant,
246 #[prop(default = Color::Primary)]
248 color: Color,
249 #[prop(default = "")]
251 data_testid: &'static str,
252) -> impl IntoView {
253 let ctx = ControlContext {
254 error,
255 disabled,
256 focused,
257 required,
258 filled: false,
259 variant,
260 color,
261 size,
262 };
263 provide_context(ctx);
264
265 let mut cls_parts = vec![
266 "form-control".to_string(),
267 variant.to_class().to_string(),
268 size.to_class().to_string(),
269 ];
270 if !margin.to_class().is_empty() {
271 cls_parts.push(margin.to_class().to_string());
272 }
273 if full_width {
274 cls_parts.push("form-control--full-width".to_string());
275 }
276 if error {
277 cls_parts.push("form-control--error".to_string());
278 }
279 if disabled {
280 cls_parts.push("form-control--disabled".to_string());
281 }
282 if focused {
283 cls_parts.push("form-control--focused".to_string());
284 }
285 if hidden_label {
286 cls_parts.push("form-control--hidden-label".to_string());
287 }
288 cls_parts.push(class.to_string());
289
290 let mut sty_parts = vec![
291 base_form_control_style().to_string(),
292 margin.to_style().to_string(),
293 ];
294 if full_width {
295 sty_parts.push("width: 100%;".to_string());
296 }
297 if disabled {
298 sty_parts.push(field_disabled_style().to_string());
299 }
300 sty_parts.push(style.to_string());
301
302 let full_class = cls_parts.join(" ");
303 let full_style = sty_parts.join(" ");
304
305 view! {
306 <div
307 id=id
308 class=full_class
309 style=full_style
310 data-testid=data_testid
311 >
312 {children()}
313 </div>
314 }
315}
316
317#[allow(non_snake_case)]
334#[component]
335pub fn FormLabel(
336 children: Children,
338 #[prop(default = "")]
340 class: &'static str,
341 #[prop(default = "")]
343 style: &'static str,
344 #[prop(default = "")]
346 id: &'static str,
347 #[prop(default = "")]
349 html_for: &'static str,
350 #[prop(optional)]
352 color: Option<Color>,
353 #[prop(default = false)]
355 disabled: bool,
356 #[prop(default = false)]
358 error: bool,
359 #[prop(default = false)]
361 filled: bool,
362 #[prop(default = false)]
364 focused: bool,
365 #[prop(default = false)]
367 required: bool,
368 #[prop(default = "")]
370 data_testid: &'static str,
371) -> impl IntoView {
372 let ctx = use_context::<ControlContext>();
373
374 let is_error = error || ctx.is_some_and(|c| c.error);
375 let is_disabled = disabled || ctx.is_some_and(|c| c.disabled);
376 let is_focused = focused || ctx.is_some_and(|c| c.focused);
377 let is_required = required || ctx.is_some_and(|c| c.required);
378 let eff_color = color.or_else(|| ctx.map(|c| c.color)).unwrap_or_default();
379 let for_attr = if !html_for.is_empty() {
380 html_for.to_string()
381 } else {
382 String::new()
383 };
384
385 let color_style = if is_error {
386 label_error_style().to_string()
387 } else if is_focused {
388 eff_color.to_label_color()
389 } else {
390 String::new()
391 };
392
393 let mut cls_parts = vec!["form-label".to_string()];
394 if is_error {
395 cls_parts.push("form-label--error".to_string());
396 }
397 if is_disabled {
398 cls_parts.push("form-label--disabled".to_string());
399 }
400 if is_focused {
401 cls_parts.push("form-label--focused".to_string());
402 }
403 if filled {
404 cls_parts.push("form-label--filled".to_string());
405 }
406 cls_parts.push(class.to_string());
407
408 let full_style = format!("{} {} {}", base_label_style(), color_style, style);
409 let full_class = cls_parts.join(" ");
410
411 view! {
412 <label
413 id=id
414 class=full_class
415 style=full_style
416 for=for_attr
417 aria-disabled=if is_disabled { "true" } else { "false" }
418 data-testid=data_testid
419 >
420 {children()}
421 <Show when=move || is_required>
422 <span style=required_asterisk_style() aria-hidden="true">{"*"}</span>
423 </Show>
424 </label>
425 }
426}
427
428#[allow(non_snake_case)]
448#[component]
449pub fn Helper(
450 children: Children,
452 #[prop(default = "")]
454 class: &'static str,
455 #[prop(default = "")]
457 style: &'static str,
458 #[prop(default = "")]
460 id: &'static str,
461 #[prop(default = false)]
463 disabled: bool,
464 #[prop(default = false)]
466 error: bool,
467 #[prop(default = false)]
469 valid: bool,
470 #[prop(default = false)]
472 filled: bool,
473 #[prop(default = false)]
475 focused: bool,
476 #[prop(default = Margin::None)]
478 margin: Margin,
479 #[prop(default = "")]
481 data_testid: &'static str,
482) -> impl IntoView {
483 let ctx = use_context::<ControlContext>();
484
485 let is_error = error || ctx.is_some_and(|c| c.error);
486 let is_disabled = disabled || ctx.is_some_and(|c| c.disabled);
487
488 let color_style = if is_error {
489 helper_error_style()
490 } else if valid {
491 helper_valid_style()
492 } else {
493 ""
494 };
495
496 let mut cls_parts = vec!["form-helper-text".to_string()];
497 if is_error {
498 cls_parts.push("form-helper-text--error".to_string());
499 }
500 if valid {
501 cls_parts.push("form-helper-text--valid".to_string());
502 }
503 if is_disabled {
504 cls_parts.push("form-helper-text--disabled".to_string());
505 }
506 if focused {
507 cls_parts.push("form-helper-text--focused".to_string());
508 }
509 if filled {
510 cls_parts.push("form-helper-text--filled".to_string());
511 }
512 if !margin.to_class().is_empty() {
513 cls_parts.push(margin.to_class().to_string());
514 }
515 cls_parts.push(class.to_string());
516
517 let full_style = format!("{} {} {}", base_helper_text_style(), color_style, style);
518 let full_class = cls_parts.join(" ");
519
520 view! {
521 <p
522 id=id
523 class=full_class
524 style=full_style
525 role=if is_error { "alert" } else { "" }
526 aria-live=if is_error { "polite" } else { "" }
527 aria-disabled=if is_disabled { "true" } else { "false" }
528 data-testid=data_testid
529 >
530 {children()}
531 </p>
532 }
533}
534
535#[allow(non_snake_case)]
557#[component]
558pub fn Group(
559 children: Children,
561 #[prop(default = "")]
563 class: &'static str,
564 #[prop(default = "")]
566 style: &'static str,
567 #[prop(default = "")]
569 id: &'static str,
570 #[prop(default = false)]
572 row: bool,
573 #[prop(default = false)]
575 error: bool,
576 #[prop(default = "")]
578 aria_label: &'static str,
579 #[prop(default = "")]
581 aria_labelledby: &'static str,
582 #[prop(default = "")]
584 data_testid: &'static str,
585) -> impl IntoView {
586 let base_style = if row {
587 base_form_group_row_style()
588 } else {
589 base_form_group_style()
590 };
591
592 let mut cls_parts = vec!["form-group".to_string()];
593 if row {
594 cls_parts.push("form-group--row".to_string());
595 }
596 if error {
597 cls_parts.push("form-group--error".to_string());
598 }
599 cls_parts.push(class.to_string());
600
601 let full_style = format!("{} {}", base_style, style);
602 let full_class = cls_parts.join(" ");
603
604 view! {
605 <div
606 id=id
607 class=full_class
608 style=full_style
609 role="group"
610 aria-label=aria_label
611 aria-labelledby=aria_labelledby
612 data-testid=data_testid
613 >
614 {children()}
615 </div>
616 }
617}
618
619#[allow(non_snake_case)]
642#[component]
643pub fn ControlLabel(
644 control: AnyView,
646 #[prop(optional)]
648 label: Option<AnyView>,
649 #[prop(default = "")]
651 class: &'static str,
652 #[prop(default = "")]
654 style: &'static str,
655 #[prop(default = "")]
657 id: &'static str,
658 #[prop(default = false)]
660 checked: bool,
661 #[prop(default = false)]
663 disabled: bool,
664 #[prop(default = LabelPlacement::End)]
666 label_placement: LabelPlacement,
667 #[prop(default = false)]
669 required: bool,
670 #[prop(default = "")]
672 value: &'static str,
673 #[prop(default = "")]
675 data_testid: &'static str,
676) -> impl IntoView {
677 let flex_style = label_placement.to_flex_direction();
678 let placement_class = label_placement.to_class();
679
680 let mut cls_parts = vec![
681 "form-control-label".to_string(),
682 placement_class.to_string(),
683 ];
684 if disabled {
685 cls_parts.push("form-control-label--disabled".to_string());
686 }
687 cls_parts.push(class.to_string());
688
689 let base_style = format!(
690 "display: inline-flex; {} gap: 8px; align-items: center; cursor: {}; user-select: none; {}",
691 flex_style,
692 if disabled { "not-allowed" } else { "pointer" },
693 style
694 );
695
696 let full_class = cls_parts.join(" ");
697
698 let _ = checked;
699 let _ = value;
700
701 view! {
702 <label
703 id=id
704 class=full_class
705 style=base_style
706 aria-disabled=if disabled { "true" } else { "false" }
707 aria-required=if required { "true" } else { "false" }
708 data-testid=data_testid
709 >
710 {control}
711 <span
712 class="form-control-label__label"
713 style="font-size: 14px; color: #d4d4d8; line-height: 1.4;"
714 >
715 {label}
716 <Show when=move || required>
717 <span style=required_asterisk_style() aria-hidden="true">{"*"}</span>
718 </Show>
719 </span>
720 </label>
721 }
722}
723
724#[allow(non_snake_case)]
755#[component]
756pub fn Field(
757 id: &'static str,
759 #[prop(default = "")]
761 name: &'static str,
762 #[prop(default = "text")]
764 r#type: &'static str,
765 #[prop(default = "")]
767 label: &'static str,
768 #[prop(default = "")]
770 placeholder: &'static str,
771 #[prop(default = "")]
773 helper_text: &'static str,
774 #[prop(default = ValidationState::None)]
776 validation_state: ValidationState,
777 #[prop(default = false)]
779 required: bool,
780 #[prop(default = false)]
782 disabled: bool,
783 #[prop(default = true)]
785 full_width: bool,
786 #[prop(default = Variant::Outlined)]
788 variant: Variant,
789 #[prop(default = Color::Primary)]
791 color: Color,
792 #[prop(default = Size::Medium)]
794 size: Size,
795 #[prop(default = ".*")]
797 pattern: &'static str,
798 #[prop(optional)]
800 maxlength: Option<usize>,
801 #[prop(optional)]
803 minlength: Option<usize>,
804 #[prop(default = "")]
806 class: &'static str,
807 #[prop(default = "")]
809 style: &'static str,
810 handle: RwSignal<String>,
812 valid_handle: RwSignal<bool>,
814 validate_function: fn(String) -> bool,
816 #[prop(default = "")]
818 data_testid: &'static str,
819) -> impl IntoView {
820 let focused = RwSignal::new(false);
821
822 let vs1 = validation_state.clone();
823 let is_error =
824 Memo::new(move |_| vs1.is_invalid() || (!valid_handle.get() && !handle.get().is_empty()));
825
826 let vs2 = validation_state.clone();
827 let is_valid = Memo::new(move |_| {
828 matches!(vs2, ValidationState::Valid) || (valid_handle.get() && !handle.get().is_empty())
829 });
830
831 let error_msg = validation_state.error_message().map(|s| s.to_string());
832 let helper_id = format!("{}-helper", id);
833
834 let input_style = move || {
835 let focus_ring = if focused.get() && !is_error.get() {
836 color.to_focus_ring()
837 } else {
838 String::new()
839 };
840 let error_ring = if is_error.get() {
841 field_error_style().to_string()
842 } else {
843 String::new()
844 };
845 let valid_ring = if is_valid.get() && !is_error.get() {
846 field_valid_style().to_string()
847 } else {
848 String::new()
849 };
850 format!(
851 "{} {} {} {} {} {} transition: all 0.2s ease;",
852 base_input_field_style(),
853 variant.to_field_style(),
854 size.to_input_style(),
855 focus_ring,
856 error_ring,
857 valid_ring
858 )
859 };
860
861 let has_helper = !helper_text.is_empty() || error_msg.is_some();
862 let input_style_static: &'static str = Box::leak(input_style().into_boxed_str());
863 let helper_id_static: &'static str = Box::leak(helper_id.clone().into_boxed_str());
864 let (handle_read, handle_write) = handle.split();
865 let (valid_read, valid_write) = valid_handle.split();
866
867 view! {
868 <Control
869 id=""
870 input_id=id
871 error=is_error.get()
872 disabled=disabled
873 focused=focused.get()
874 full_width=full_width
875 required=required
876 variant=variant
877 color=color
878 size=size
879 class=class
880 style=style
881 data_testid=data_testid
882 >
883 <Show when=move || !label.is_empty()>
884 <FormLabel
885 html_for=id
886 error=is_error.get()
887 focused=focused.get()
888 required=required
889 disabled=disabled
890 >
891 {label}
892 </FormLabel>
893 </Show>
894 <Input
895 r#type=r#type
896 id=id
897 name=name
898 placeholder=placeholder
899 handle=(handle_read, handle_write)
900 valid_handle=(valid_read, valid_write)
901 validate_function=validate_function
902 required=required
903 disabled=disabled
904 pattern=pattern
905 maxlength=maxlength
906 minlength=minlength
907 input_style=input_style_static
908 aria_describedby=helper_id_static
909 aria_required=if required { "true" } else { "false" }
910 aria_invalid=if is_error.get() { "true" } else { "false" }
911 otp_mode=true
912 on_focus=Callback::new(move |_: FocusEvent| focused.set(true))
913 on_blur=Callback::new(move |_: FocusEvent| focused.set(false))
914 />
915 <Show when=move || has_helper>
916 {
917 let content = if let Some(ref msg) = error_msg {
918 msg.clone()
919 } else if is_error.get() {
920 "Invalid value.".to_string()
921 } else {
922 helper_text.to_string()
923 };
924 view! {
925 <Helper
926 id=helper_id_static
927 error=is_error.get()
928 valid=is_valid.get() && !is_error.get()
929 >
930 {content}
931 </Helper>
932 }
933 }
934 </Show>
935 </Control>
936 }
937}
938
939