1#![doc = include_str!("../YEW.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::yew::Input;
18use yew::prelude::*;
19
20#[derive(Clone, Debug, PartialEq)]
25pub struct FormContext {
26 pub validation_behavior: ValidationBehavior,
28 pub is_submitting: bool,
30 pub novalidate: bool,
32}
33
34#[derive(Clone, Debug, PartialEq)]
39pub struct ControlContext {
40 pub error: bool,
42 pub disabled: bool,
44 pub focused: bool,
46 pub required: bool,
48 pub filled: bool,
50 pub variant: Variant,
52 pub color: Color,
54 pub size: Size,
56 pub input_id: String,
58}
59
60#[derive(Properties, PartialEq, Clone)]
62pub struct FormProps {
63 #[prop_or_default]
65 pub children: Children,
66
67 #[prop_or_default]
69 pub class: &'static str,
70
71 #[prop_or_default]
73 pub style: &'static str,
74
75 #[prop_or_default]
77 pub id: &'static str,
78
79 #[prop_or_default]
81 pub action: &'static str,
82
83 #[prop_or_default]
85 pub method: Method,
86
87 #[prop_or_default]
89 pub enc_type: EncType,
90
91 #[prop_or_default]
93 pub target: Target,
94
95 #[prop_or_default]
98 pub novalidate: bool,
99
100 #[prop_or("on")]
102 pub autocomplete: &'static str,
103
104 #[prop_or_default]
106 pub name: &'static str,
107
108 #[prop_or_default]
110 pub validation_behavior: ValidationBehavior,
111
112 #[prop_or_default]
114 pub on_submit: Callback<SubmitEvent>,
115
116 #[prop_or_default]
118 pub on_reset: Callback<Event>,
119
120 #[prop_or_default]
122 pub on_invalid: Callback<Event>,
123
124 #[prop_or_default]
126 pub aria_label: &'static str,
127
128 #[prop_or_default]
130 pub aria_labelledby: &'static str,
131
132 #[prop_or_default]
134 pub data_testid: &'static str,
135}
136
137#[function_component(Form)]
170pub fn form(props: &FormProps) -> Html {
171 let is_submitting = use_state(|| false);
172
173 let ctx = FormContext {
174 validation_behavior: props.validation_behavior,
175 is_submitting: *is_submitting,
176 novalidate: props.novalidate || props.validation_behavior == ValidationBehavior::Aria,
177 };
178
179 let on_submit = {
180 let user_cb = props.on_submit.clone();
181 let is_submitting = is_submitting.clone();
182 Callback::from(move |e: SubmitEvent| {
183 is_submitting.set(true);
184 user_cb.emit(e);
185 is_submitting.set(false);
186 })
187 };
188
189 let on_reset = props.on_reset.clone();
190 let on_invalid = props.on_invalid.clone();
191
192 let novalidate = props.novalidate || props.validation_behavior == ValidationBehavior::Aria;
193 let full_style = format!("{} {}", base_form_style(), props.style);
194
195 html! {
196 <ContextProvider<FormContext> context={ctx}>
197 <form
198 id={props.id}
199 class={format!("form {}", props.class)}
200 style={full_style}
201 action={props.action}
202 method={props.method.as_str()}
203 enctype={props.enc_type.as_str()}
204 target={props.target.as_str()}
205 novalidate={novalidate}
206 autocomplete={props.autocomplete}
207 name={props.name}
208 onsubmit={on_submit}
209 onreset={Callback::from(move |e: Event| on_reset.emit(e))}
210 oninvalid={Callback::from(move |e: Event| on_invalid.emit(e))}
211 aria-label={props.aria_label}
212 aria-labelledby={props.aria_labelledby}
213 data-testid={props.data_testid}
214 >
215 { for props.children.iter() }
216 </form>
217 </ContextProvider<FormContext>>
218 }
219}
220
221#[derive(Properties, PartialEq, Clone)]
223pub struct ControlProps {
224 #[prop_or_default]
226 pub children: Children,
227
228 #[prop_or_default]
230 pub class: &'static str,
231
232 #[prop_or_default]
234 pub style: &'static str,
235
236 #[prop_or_default]
238 pub id: &'static str,
239
240 #[prop_or_default]
242 pub input_id: &'static str,
243
244 #[prop_or_default]
246 pub disabled: bool,
247
248 #[prop_or_default]
250 pub error: bool,
251
252 #[prop_or_default]
254 pub focused: bool,
255
256 #[prop_or_default]
258 pub full_width: bool,
259
260 #[prop_or_default]
262 pub hidden_label: bool,
263
264 #[prop_or_default]
266 pub margin: Margin,
267
268 #[prop_or_default]
270 pub required: bool,
271
272 #[prop_or_default]
274 pub size: Size,
275
276 #[prop_or_default]
278 pub variant: Variant,
279
280 #[prop_or_default]
282 pub color: Color,
283
284 #[prop_or_default]
286 pub data_testid: &'static str,
287}
288
289#[function_component(Control)]
318pub fn form_control(props: &ControlProps) -> Html {
319 let ctx = ControlContext {
320 error: props.error,
321 disabled: props.disabled,
322 focused: props.focused,
323 required: props.required,
324 filled: false,
325 variant: props.variant,
326 color: props.color,
327 size: props.size,
328 input_id: props.input_id.to_string(),
329 };
330
331 let mut class_parts = vec!["form-control".to_string()];
332 class_parts.push(props.variant.to_class().to_string());
333 class_parts.push(props.size.to_class().to_string());
334 if !props.margin.to_class().is_empty() {
335 class_parts.push(props.margin.to_class().to_string());
336 }
337 if props.full_width {
338 class_parts.push("form-control--full-width".to_string());
339 }
340 if props.error {
341 class_parts.push("form-control--error".to_string());
342 }
343 if props.disabled {
344 class_parts.push("form-control--disabled".to_string());
345 }
346 if props.focused {
347 class_parts.push("form-control--focused".to_string());
348 }
349 if props.hidden_label {
350 class_parts.push("form-control--hidden-label".to_string());
351 }
352 class_parts.push(props.class.to_string());
353
354 let mut style_parts = vec![base_form_control_style().to_string()];
355 style_parts.push(props.margin.to_style().to_string());
356 if props.full_width {
357 style_parts.push("width: 100%;".to_string());
358 }
359 if props.disabled {
360 style_parts.push(field_disabled_style().to_string());
361 }
362 style_parts.push(props.style.to_string());
363
364 html! {
365 <ContextProvider<ControlContext> context={ctx}>
366 <div
367 id={props.id}
368 class={class_parts.join(" ")}
369 style={style_parts.join(" ")}
370 data-testid={props.data_testid}
371 >
372 { for props.children.iter() }
373 </div>
374 </ContextProvider<ControlContext>>
375 }
376}
377
378#[derive(Properties, PartialEq, Clone)]
380pub struct FormLabelProps {
381 #[prop_or_default]
383 pub children: Children,
384
385 #[prop_or_default]
387 pub class: &'static str,
388
389 #[prop_or_default]
391 pub style: &'static str,
392
393 #[prop_or_default]
395 pub id: &'static str,
396
397 #[prop_or_default]
399 pub html_for: &'static str,
400
401 #[prop_or_default]
403 pub color: Option<Color>,
404
405 #[prop_or_default]
407 pub disabled: bool,
408
409 #[prop_or_default]
411 pub error: bool,
412
413 #[prop_or_default]
415 pub filled: bool,
416
417 #[prop_or_default]
419 pub focused: bool,
420
421 #[prop_or_default]
423 pub required: bool,
424
425 #[prop_or_default]
427 pub data_testid: &'static str,
428}
429
430#[function_component(FormLabel)]
453pub fn form_label(props: &FormLabelProps) -> Html {
454 let ctx = use_context::<ControlContext>();
455
456 let error = props.error || ctx.as_ref().is_some_and(|c| c.error);
457 let disabled = props.disabled || ctx.as_ref().is_some_and(|c| c.disabled);
458 let focused = props.focused || ctx.as_ref().is_some_and(|c| c.focused);
459 let required = props.required || ctx.as_ref().is_some_and(|c| c.required);
460 let color = props
461 .color
462 .or_else(|| ctx.as_ref().map(|c| c.color))
463 .unwrap_or_default();
464 let linked_id = if !props.html_for.is_empty() {
465 props.html_for.to_string()
466 } else {
467 ctx.as_ref().map(|c| c.input_id.clone()).unwrap_or_default()
468 };
469
470 let color_style = if error {
471 label_error_style().to_string()
472 } else if focused {
473 color.to_label_color()
474 } else {
475 String::new()
476 };
477
478 let mut class_parts = vec!["form-label".to_string()];
479 if error {
480 class_parts.push("form-label--error".to_string());
481 }
482 if disabled {
483 class_parts.push("form-label--disabled".to_string());
484 }
485 if focused {
486 class_parts.push("form-label--focused".to_string());
487 }
488 if props.filled {
489 class_parts.push("form-label--filled".to_string());
490 }
491 class_parts.push(props.class.to_string());
492
493 let full_style = format!("{} {} {}", base_label_style(), color_style, props.style);
494
495 html! {
496 <label
497 id={props.id}
498 class={class_parts.join(" ")}
499 style={full_style}
500 for={linked_id}
501 aria-disabled={if disabled { "true" } else { "false" }}
502 data-testid={props.data_testid}
503 >
504 { for props.children.iter() }
505 if required {
506 <span style={required_asterisk_style()} aria-hidden="true">{ "*" }</span>
507 }
508 </label>
509 }
510}
511
512#[derive(Properties, PartialEq, Clone)]
514pub struct HelperProps {
515 #[prop_or_default]
517 pub children: Children,
518
519 #[prop_or_default]
521 pub class: &'static str,
522
523 #[prop_or_default]
525 pub style: &'static str,
526
527 #[prop_or_default]
529 pub id: &'static str,
530
531 #[prop_or_default]
533 pub disabled: bool,
534
535 #[prop_or_default]
537 pub error: bool,
538
539 #[prop_or_default]
541 pub valid: bool,
542
543 #[prop_or_default]
545 pub filled: bool,
546
547 #[prop_or_default]
549 pub focused: bool,
550
551 #[prop_or_default]
553 pub margin: Margin,
554
555 #[prop_or_default]
557 pub required: bool,
558
559 #[prop_or_default]
561 pub data_testid: &'static str,
562}
563
564#[function_component(Helper)]
591pub fn form_helper_text(props: &HelperProps) -> Html {
592 let ctx = use_context::<ControlContext>();
593
594 let error = props.error || ctx.as_ref().is_some_and(|c| c.error);
595 let disabled = props.disabled || ctx.as_ref().is_some_and(|c| c.disabled);
596
597 let color_style = if error {
598 helper_error_style()
599 } else if props.valid {
600 helper_valid_style()
601 } else {
602 ""
603 };
604
605 let mut class_parts = vec!["form-helper-text".to_string()];
606 if error {
607 class_parts.push("form-helper-text--error".to_string());
608 }
609 if props.valid {
610 class_parts.push("form-helper-text--valid".to_string());
611 }
612 if disabled {
613 class_parts.push("form-helper-text--disabled".to_string());
614 }
615 if props.focused {
616 class_parts.push("form-helper-text--focused".to_string());
617 }
618 if props.filled {
619 class_parts.push("form-helper-text--filled".to_string());
620 }
621 if !props.margin.to_class().is_empty() {
622 class_parts.push(props.margin.to_class().to_string());
623 }
624 class_parts.push(props.class.to_string());
625
626 let full_style = format!(
627 "{} {} {}",
628 base_helper_text_style(),
629 color_style,
630 props.style
631 );
632
633 html! {
634 <p
635 id={props.id}
636 class={class_parts.join(" ")}
637 style={full_style}
638 role={if error { "alert" } else { "" }}
639 aria-live={if error { "polite" } else { "" }}
640 aria-disabled={if disabled { "true" } else { "false" }}
641 data-testid={props.data_testid}
642 >
643 { for props.children.iter() }
644 </p>
645 }
646}
647
648#[derive(Properties, PartialEq, Clone)]
650pub struct GroupProps {
651 #[prop_or_default]
653 pub children: Children,
654
655 #[prop_or_default]
657 pub class: &'static str,
658
659 #[prop_or_default]
661 pub style: &'static str,
662
663 #[prop_or_default]
665 pub id: &'static str,
666
667 #[prop_or_default]
669 pub row: bool,
670
671 #[prop_or_default]
673 pub error: bool,
674
675 #[prop_or_default]
677 pub aria_label: &'static str,
678
679 #[prop_or_default]
681 pub aria_labelledby: &'static str,
682
683 #[prop_or_default]
685 pub data_testid: &'static str,
686}
687
688#[function_component(Group)]
715pub fn form_group(props: &GroupProps) -> Html {
716 let base_style = if props.row {
717 base_form_group_row_style()
718 } else {
719 base_form_group_style()
720 };
721
722 let mut class_parts = vec!["form-group".to_string()];
723 if props.row {
724 class_parts.push("form-group--row".to_string());
725 }
726 if props.error {
727 class_parts.push("form-group--error".to_string());
728 }
729 class_parts.push(props.class.to_string());
730
731 let full_style = format!("{} {}", base_style, props.style);
732
733 html! {
734 <div
735 id={props.id}
736 class={class_parts.join(" ")}
737 style={full_style}
738 role="group"
739 aria-label={props.aria_label}
740 aria-labelledby={props.aria_labelledby}
741 data-testid={props.data_testid}
742 >
743 { for props.children.iter() }
744 </div>
745 }
746}
747
748#[derive(Properties, PartialEq, Clone)]
750pub struct ControlLabelProps {
751 pub control: Html,
753
754 #[prop_or_default]
756 pub label: Html,
757
758 #[prop_or_default]
760 pub class: &'static str,
761
762 #[prop_or_default]
764 pub style: &'static str,
765
766 #[prop_or_default]
768 pub id: &'static str,
769
770 #[prop_or_default]
772 pub checked: bool,
773
774 #[prop_or_default]
776 pub disabled: bool,
777
778 #[prop_or_default]
780 pub label_placement: LabelPlacement,
781
782 #[prop_or_default]
784 pub required: bool,
785
786 #[prop_or_default]
788 pub value: &'static str,
789
790 #[prop_or_default]
792 pub data_testid: &'static str,
793}
794
795#[function_component(ControlLabel)]
827pub fn form_control_label(props: &ControlLabelProps) -> Html {
828 let flex_style = props.label_placement.to_flex_direction();
829 let placement_class = props.label_placement.to_class();
830
831 let mut class_parts = vec!["form-control-label".to_string()];
832 class_parts.push(placement_class.to_string());
833 if props.disabled {
834 class_parts.push("form-control-label--disabled".to_string());
835 }
836 class_parts.push(props.class.to_string());
837
838 let base_style = format!(
839 "display: inline-flex; {} gap: 8px; align-items: center; cursor: {}; user-select: none;",
840 flex_style,
841 if props.disabled {
842 "not-allowed"
843 } else {
844 "pointer"
845 }
846 );
847 let full_style = format!("{} {}", base_style, props.style);
848
849 html! {
850 <label
851 id={props.id}
852 class={class_parts.join(" ")}
853 style={full_style}
854 aria-disabled={if props.disabled { "true" } else { "false" }}
855 aria-required={if props.required { "true" } else { "false" }}
856 data-testid={props.data_testid}
857 >
858 { props.control.clone() }
859 <span
860 class="form-control-label__label"
861 style="font-size: 14px; color: #d4d4d8; line-height: 1.4;"
862 >
863 { props.label.clone() }
864 if props.required {
865 <span style={required_asterisk_style()} aria-hidden="true">{ "*" }</span>
866 }
867 </span>
868 </label>
869 }
870}
871
872#[derive(Properties, PartialEq, Clone)]
875pub struct FieldProps {
876 pub id: &'static str,
878
879 #[prop_or_default]
881 pub name: &'static str,
882
883 #[prop_or("text")]
885 pub r#type: &'static str,
886
887 #[prop_or_default]
889 pub label: &'static str,
890
891 #[prop_or_default]
893 pub placeholder: &'static str,
894
895 #[prop_or_default]
897 pub helper_text: &'static str,
898
899 #[prop_or_default]
901 pub validation_state: ValidationState,
902
903 #[prop_or_default]
905 pub required: bool,
906
907 #[prop_or_default]
909 pub disabled: bool,
910
911 #[prop_or(true)]
913 pub full_width: bool,
914
915 #[prop_or_default]
917 pub variant: Variant,
918
919 #[prop_or_default]
921 pub color: Color,
922
923 #[prop_or_default]
925 pub size: Size,
926
927 #[prop_or(".*")]
929 pub pattern: &'static str,
930
931 #[prop_or_default]
933 pub maxlength: Option<usize>,
934
935 #[prop_or_default]
937 pub minlength: Option<usize>,
938
939 #[prop_or_default]
941 pub class: &'static str,
942
943 #[prop_or_default]
945 pub style: &'static str,
946
947 pub handle: UseStateHandle<String>,
949
950 pub valid_handle: UseStateHandle<bool>,
952
953 pub validate_function: Callback<String, bool>,
955
956 #[prop_or_default]
958 pub data_testid: &'static str,
959}
960
961#[function_component(Field)]
1001pub fn form_field(props: &FieldProps) -> Html {
1002 let is_error = props.validation_state.is_invalid()
1003 || (!(*props.valid_handle) && !(*props.handle).is_empty());
1004 let is_valid = matches!(props.validation_state, ValidationState::Valid)
1005 || ((*props.valid_handle) && !(*props.handle).is_empty());
1006
1007 let error_msg = props
1008 .validation_state
1009 .error_message()
1010 .map(|s| s.to_string());
1011
1012 let helper_id: &'static str = Box::leak(format!("{}-helper", props.id).into_boxed_str());
1013 let focused = use_state(|| false);
1014
1015 let on_focus = {
1016 let focused = focused.clone();
1017 Callback::from(move |_| focused.set(true))
1018 };
1019 let on_blur = {
1020 let focused = focused.clone();
1021 Callback::from(move |_| focused.set(false))
1022 };
1023
1024 let field_border = props.variant.to_field_style();
1025 let input_size = props.size.to_input_style();
1026 let focus_ring = if *focused && !is_error {
1027 props.color.to_focus_ring()
1028 } else {
1029 String::new()
1030 };
1031 let error_ring = if is_error {
1032 field_error_style().to_string()
1033 } else {
1034 String::new()
1035 };
1036 let valid_ring = if is_valid && !is_error {
1037 field_valid_style().to_string()
1038 } else {
1039 String::new()
1040 };
1041
1042 let input_style = format!(
1043 "{} {} {} {} {} {} transition: all 0.2s ease;",
1044 base_input_field_style(),
1045 field_border,
1046 input_size,
1047 focus_ring,
1048 error_ring,
1049 valid_ring
1050 );
1051
1052 let input_style_static: &'static str = Box::leak(input_style.into_boxed_str());
1053
1054 let helper_content = if let Some(ref msg) = error_msg {
1055 msg.clone()
1056 } else if is_error {
1057 "Invalid value.".to_string()
1058 } else if !props.helper_text.is_empty() {
1059 props.helper_text.to_string()
1060 } else {
1061 String::new()
1062 };
1063
1064 let node_ref = use_node_ref();
1065 let validate_cb = props.validate_function.clone();
1066
1067 html! {
1068 <Control
1069 input_id={props.id}
1070 error={is_error}
1071 disabled={props.disabled}
1072 focused={*focused}
1073 full_width={props.full_width}
1074 required={props.required}
1075 variant={props.variant}
1076 color={props.color}
1077 size={props.size}
1078 class={props.class}
1079 style={props.style}
1080 data_testid={props.data_testid}
1081 >
1082 if !props.label.is_empty() {
1083 <FormLabel
1084 html_for={props.id}
1085 error={is_error}
1086 focused={*focused}
1087 required={props.required}
1088 disabled={props.disabled}
1089 >
1090 { html!{props.label} }
1091 </FormLabel>
1092 }
1093 <Input
1094 r#type={props.r#type}
1095 id={props.id}
1096 name={props.name}
1097 placeholder={props.placeholder}
1098 r#ref={node_ref}
1099 handle={props.handle.clone()}
1100 valid_handle={props.valid_handle.clone()}
1101 validate_function={validate_cb}
1102 required={props.required}
1103 disabled={props.disabled}
1104 pattern={props.pattern}
1105 maxlength={props.maxlength}
1106 minlength={props.minlength}
1107 input_style={input_style_static}
1108 aria_describedby={helper_id}
1109 aria_required={if props.required { "true" } else { "false" }}
1110 aria_invalid={if is_error { "true" } else { "false" }}
1111 otp_mode=true
1112 on_focus={on_focus}
1113 on_blur={on_blur}
1114 />
1115 if !helper_content.is_empty() {
1116 <Helper id={helper_id} error={is_error} valid={is_valid && !is_error}>
1117 { html!{helper_content} }
1118 </Helper>
1119 }
1120 </Control>
1121 }
1122}
1123
1124