1#![doc = include_str!("../DIOXUS.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};
17use dioxus::prelude::*;
18pub use input_rs::dioxus::Input;
19
20#[derive(Clone, Debug, PartialEq)]
22pub struct FormContext {
23 pub validation_behavior: ValidationBehavior,
25 pub is_submitting: bool,
27 pub novalidate: bool,
29}
30
31#[derive(Clone, Debug, PartialEq)]
33pub struct ControlContext {
34 pub error: bool,
36 pub disabled: bool,
38 pub focused: bool,
40 pub required: bool,
42 pub filled: bool,
44 pub variant: Variant,
46 pub color: Color,
48 pub size: Size,
50 pub input_id: &'static str,
52}
53
54#[derive(Props, Clone, PartialEq)]
56pub struct FormProps {
57 #[props(default)]
58 pub children: Element,
59 #[props(default)]
60 pub class: &'static str,
61 #[props(default)]
62 pub style: &'static str,
63 #[props(default)]
64 pub id: &'static str,
65 #[props(default)]
66 pub action: &'static str,
67 #[props(default)]
68 pub method: Method,
69 #[props(default)]
70 pub enc_type: EncType,
71 #[props(default)]
72 pub target: Target,
73 #[props(default)]
74 pub novalidate: bool,
75 #[props(default = "on")]
76 pub autocomplete: &'static str,
77 #[props(default)]
78 pub name: &'static str,
79 #[props(default)]
80 pub validation_behavior: ValidationBehavior,
81 #[props(default)]
82 pub on_submit: Option<Callback<FormEvent>>,
83 #[props(default)]
84 pub on_reset: Option<Callback<FormEvent>>,
85 #[props(default)]
86 pub aria_label: &'static str,
87 #[props(default)]
88 pub aria_labelledby: &'static str,
89 #[props(default)]
90 pub data_testid: &'static str,
91}
92
93#[component]
104pub fn Form(props: FormProps) -> Element {
105 let mut is_submitting = use_signal(|| false);
106
107 let novalidate = props.novalidate || props.validation_behavior == ValidationBehavior::Aria;
108
109 let ctx = FormContext {
110 validation_behavior: props.validation_behavior,
111 is_submitting: is_submitting(),
112 novalidate,
113 };
114
115 use_context_provider(|| ctx);
116
117 let on_submit = move |e: FormEvent| {
118 is_submitting.set(true);
119 if let Some(cb) = &props.on_submit {
120 cb.call(e);
121 }
122 is_submitting.set(false);
123 };
124
125 let on_reset = move |e: FormEvent| {
126 if let Some(cb) = &props.on_reset {
127 cb.call(e);
128 }
129 };
130
131 let full_style = format!("{} {}", base_form_style(), props.style);
132
133 rsx! {
134 form {
135 id: props.id,
136 class: "form {props.class}",
137 style: "{full_style}",
138 action: props.action,
139 method: props.method.as_str(),
140 enctype: props.enc_type.as_str(),
141 target: props.target.as_str(),
142 novalidate: novalidate,
143 autocomplete: props.autocomplete,
144 name: props.name,
145 onsubmit: on_submit,
146 onreset: on_reset,
147 aria_label: props.aria_label,
148 aria_labelledby: props.aria_labelledby,
149 "data-testid": props.data_testid,
150 {props.children}
151 }
152 }
153}
154
155#[derive(Props, Clone, PartialEq)]
157pub struct ControlProps {
158 #[props(default)]
159 pub children: Element,
160 #[props(default)]
161 pub class: &'static str,
162 #[props(default)]
163 pub style: &'static str,
164 #[props(default)]
165 pub id: &'static str,
166 #[props(default)]
167 pub input_id: &'static str,
168 #[props(default)]
169 pub disabled: bool,
170 #[props(default)]
171 pub error: bool,
172 #[props(default)]
173 pub focused: bool,
174 #[props(default)]
175 pub full_width: bool,
176 #[props(default)]
177 pub hidden_label: bool,
178 #[props(default)]
179 pub margin: Margin,
180 #[props(default)]
181 pub required: bool,
182 #[props(default)]
183 pub size: Size,
184 #[props(default)]
185 pub variant: Variant,
186 #[props(default)]
187 pub color: Color,
188 #[props(default)]
189 pub data_testid: &'static str,
190}
191
192#[component]
198pub fn Control(props: ControlProps) -> Element {
199 let ctx = ControlContext {
200 error: props.error,
201 disabled: props.disabled,
202 focused: props.focused,
203 required: props.required,
204 filled: false,
205 variant: props.variant,
206 color: props.color,
207 size: props.size,
208 input_id: props.input_id,
209 };
210
211 use_context_provider(|| ctx);
212
213 let mut cls = format!(
214 "form-control {} {} {}",
215 props.variant.to_class(),
216 props.size.to_class(),
217 props.margin.to_class()
218 );
219 if props.full_width {
220 cls.push_str(" form-control--full-width");
221 }
222 if props.error {
223 cls.push_str(" form-control--error");
224 }
225 if props.disabled {
226 cls.push_str(" form-control--disabled");
227 }
228 if props.focused {
229 cls.push_str(" form-control--focused");
230 }
231 if props.hidden_label {
232 cls.push_str(" form-control--hidden-label");
233 }
234 cls.push(' ');
235 cls.push_str(props.class);
236
237 let mut sty = format!("{} {} ", base_form_control_style(), props.margin.to_style());
238 if props.full_width {
239 sty.push_str("width: 100%; ");
240 }
241 if props.disabled {
242 sty.push_str(field_disabled_style());
243 }
244 sty.push_str(props.style);
245
246 rsx! {
247 div {
248 id: props.id,
249 class: "{cls}",
250 style: "{sty}",
251 "data-testid": props.data_testid,
252 {props.children}
253 }
254 }
255}
256
257#[derive(Props, Clone, PartialEq)]
259pub struct FormLabelProps {
260 #[props(default)]
261 pub children: Element,
262 #[props(default)]
263 pub class: &'static str,
264 #[props(default)]
265 pub style: &'static str,
266 #[props(default)]
267 pub id: &'static str,
268 #[props(default)]
269 pub html_for: &'static str,
270 #[props(default = None)]
271 pub color: Option<Color>,
272 #[props(default)]
273 pub disabled: bool,
274 #[props(default)]
275 pub error: bool,
276 #[props(default)]
277 pub filled: bool,
278 #[props(default)]
279 pub focused: bool,
280 #[props(default)]
281 pub required: bool,
282 #[props(default)]
283 pub data_testid: &'static str,
284}
285
286#[component]
291pub fn FormLabel(props: FormLabelProps) -> Element {
292 let ctx = try_consume_context::<ControlContext>();
293
294 let error = props.error || ctx.as_ref().is_some_and(|c| c.error);
295 let disabled = props.disabled || ctx.as_ref().is_some_and(|c| c.disabled);
296 let focused = props.focused || ctx.as_ref().is_some_and(|c| c.focused);
297 let required = props.required || ctx.as_ref().is_some_and(|c| c.required);
298 let color = props
299 .color
300 .or_else(|| ctx.as_ref().map(|c| c.color))
301 .unwrap_or_default();
302 let linked_id = if !props.html_for.is_empty() {
303 props.html_for.to_string()
304 } else {
305 ctx.as_ref()
306 .map(|c| c.input_id.to_string())
307 .unwrap_or_default()
308 };
309
310 let color_style = if error {
311 label_error_style().to_string()
312 } else if focused {
313 color.to_label_color()
314 } else {
315 String::new()
316 };
317
318 let mut cls = "form-label".to_string();
319 if error {
320 cls.push_str(" form-label--error");
321 }
322 if disabled {
323 cls.push_str(" form-label--disabled");
324 }
325 if focused {
326 cls.push_str(" form-label--focused");
327 }
328 if props.filled {
329 cls.push_str(" form-label--filled");
330 }
331 cls.push(' ');
332 cls.push_str(props.class);
333
334 let full_style = format!("{} {} {}", base_label_style(), color_style, props.style);
335
336 rsx! {
337 label {
338 id: props.id,
339 class: "{cls}",
340 style: "{full_style}",
341 r#for: "{linked_id}",
342 aria_disabled: if disabled { "true" } else { "false" },
343 "data-testid": props.data_testid,
344 {props.children}
345 if required {
346 span {
347 style: required_asterisk_style(),
348 aria_hidden: "true",
349 "*"
350 }
351 }
352 }
353 }
354}
355
356#[derive(Props, Clone, PartialEq)]
358pub struct HelperProps {
359 #[props(default)]
360 pub children: Element,
361 #[props(default)]
362 pub class: &'static str,
363 #[props(default)]
364 pub style: &'static str,
365 #[props(default)]
366 pub id: &'static str,
367 #[props(default)]
368 pub disabled: bool,
369 #[props(default)]
370 pub error: bool,
371 #[props(default)]
372 pub valid: bool,
373 #[props(default)]
374 pub filled: bool,
375 #[props(default)]
376 pub focused: bool,
377 #[props(default)]
378 pub margin: Margin,
379 #[props(default)]
380 pub data_testid: &'static str,
381}
382
383#[component]
388pub fn Helper(props: HelperProps) -> Element {
389 let ctx = try_consume_context::<ControlContext>();
390
391 let error = props.error || ctx.as_ref().is_some_and(|c| c.error);
392 let disabled = props.disabled || ctx.as_ref().is_some_and(|c| c.disabled);
393
394 let color_style = if error {
395 helper_error_style()
396 } else if props.valid {
397 helper_valid_style()
398 } else {
399 ""
400 };
401
402 let mut cls = "form-helper-text".to_string();
403 if error {
404 cls.push_str(" form-helper-text--error");
405 }
406 if props.valid {
407 cls.push_str(" form-helper-text--valid");
408 }
409 if disabled {
410 cls.push_str(" form-helper-text--disabled");
411 }
412 if props.focused {
413 cls.push_str(" form-helper-text--focused");
414 }
415 if props.filled {
416 cls.push_str(" form-helper-text--filled");
417 }
418 if !props.margin.to_class().is_empty() {
419 cls.push_str(&format!(" {}", props.margin.to_class()));
420 }
421 cls.push(' ');
422 cls.push_str(props.class);
423
424 let full_style = format!(
425 "{} {} {}",
426 base_helper_text_style(),
427 color_style,
428 props.style
429 );
430
431 rsx! {
432 p {
433 id: props.id,
434 class: "{cls}",
435 style: "{full_style}",
436 role: if error { "alert" } else { "" },
437 aria_live: if error { "polite" } else { "" },
438 aria_disabled: if disabled { "true" } else { "false" },
439 "data-testid": props.data_testid,
440 {props.children}
441 }
442 }
443}
444
445#[derive(Props, Clone, PartialEq)]
447pub struct GroupProps {
448 #[props(default)]
449 pub children: Element,
450 #[props(default)]
451 pub class: &'static str,
452 #[props(default)]
453 pub style: &'static str,
454 #[props(default)]
455 pub id: &'static str,
456 #[props(default)]
457 pub row: bool,
458 #[props(default)]
459 pub error: bool,
460 #[props(default = "")]
461 pub aria_label: &'static str,
462 #[props(default)]
463 pub aria_labelledby: &'static str,
464 #[props(default)]
465 pub data_testid: &'static str,
466}
467
468#[component]
473pub fn Group(props: GroupProps) -> Element {
474 let base_style = if props.row {
475 base_form_group_row_style()
476 } else {
477 base_form_group_style()
478 };
479
480 let mut cls = "form-group".to_string();
481 if props.row {
482 cls.push_str(" form-group--row");
483 }
484 if props.error {
485 cls.push_str(" form-group--error");
486 }
487 cls.push(' ');
488 cls.push_str(props.class);
489
490 let full_style = format!("{} {}", base_style, props.style);
491
492 rsx! {
493 div {
494 id: props.id,
495 class: "{cls}",
496 style: "{full_style}",
497 role: "group",
498 aria_label: props.aria_label,
499 aria_labelledby: props.aria_labelledby,
500 "data-testid": props.data_testid,
501 {props.children}
502 }
503 }
504}
505
506#[derive(Props, Clone, PartialEq)]
508pub struct ControlLabelProps {
509 pub control: Element,
511 pub label: Option<Element>,
513 #[props(default)]
514 pub class: &'static str,
515 #[props(default)]
516 pub style: &'static str,
517 #[props(default)]
518 pub id: &'static str,
519 #[props(default)]
520 pub checked: bool,
521 #[props(default)]
522 pub disabled: bool,
523 #[props(default)]
524 pub label_placement: LabelPlacement,
525 #[props(default)]
526 pub required: bool,
527 #[props(default)]
528 pub value: &'static str,
529 #[props(default)]
530 pub data_testid: &'static str,
531}
532
533#[component]
538pub fn ControlLabel(props: ControlLabelProps) -> Element {
539 let flex_style = props.label_placement.to_flex_direction();
540 let placement_class = props.label_placement.to_class();
541
542 let mut cls = format!("form-control-label {} ", placement_class);
543 if props.disabled {
544 cls.push_str("form-control-label--disabled ");
545 }
546 cls.push_str(props.class);
547
548 let base_style = format!(
549 "display: inline-flex; {} gap: 8px; align-items: center; cursor: {}; user-select: none; {} {}",
550 flex_style,
551 if props.disabled {
552 "not-allowed"
553 } else {
554 "pointer"
555 },
556 props.style,
557 ""
558 );
559
560 rsx! {
561 label {
562 id: props.id,
563 class: "{cls}",
564 style: "{base_style}",
565 aria_disabled: if props.disabled { "true" } else { "false" },
566 aria_required: if props.required { "true" } else { "false" },
567 "data-testid": props.data_testid,
568 {props.control}
569 span {
570 class: "form-control-label__label",
571 style: "font-size: 14px; color: #d4d4d8; line-height: 1.4;",
572 {props.label}
573 if props.required {
574 span {
575 style: required_asterisk_style(),
576 aria_hidden: "true",
577 "*"
578 }
579 }
580 }
581 }
582 }
583}
584
585#[derive(Props, Clone, PartialEq)]
587#[allow(unpredictable_function_pointer_comparisons)]
588pub struct FieldProps {
589 pub id: &'static str,
590 #[props(default)]
591 pub name: &'static str,
592 #[props(default = "text")]
593 pub r#type: &'static str,
594 #[props(default)]
595 pub label: &'static str,
596 #[props(default)]
597 pub placeholder: &'static str,
598 #[props(default)]
599 pub helper_text: &'static str,
600 #[props(default)]
601 pub validation_state: ValidationState,
602 #[props(default)]
603 pub required: bool,
604 #[props(default)]
605 pub disabled: bool,
606 #[props(default = true)]
607 pub full_width: bool,
608 #[props(default)]
609 pub variant: Variant,
610 #[props(default)]
611 pub color: Color,
612 #[props(default)]
613 pub size: Size,
614 #[props(default = ".*")]
615 pub pattern: &'static str,
616 #[props(default = None)]
617 pub maxlength: Option<usize>,
618 #[props(default = None)]
619 pub minlength: Option<usize>,
620 #[props(default)]
621 pub class: &'static str,
622 #[props(default)]
623 pub style: &'static str,
624 pub handle: Signal<String>,
625 pub valid_handle: Signal<bool>,
626 pub validate_function: fn(String) -> bool,
627 #[props(default)]
628 pub data_testid: &'static str,
629}
630
631#[component]
636pub fn Field(props: FieldProps) -> Element {
637 let mut focused = use_signal(|| false);
638
639 let is_error = props.validation_state.is_invalid()
640 || (!(props.valid_handle)() && !(props.handle)().is_empty());
641 let is_valid = matches!(props.validation_state, ValidationState::Valid)
642 || ((props.valid_handle)() && !(props.handle)().is_empty());
643
644 let error_msg = props
645 .validation_state
646 .error_message()
647 .map(|s| s.to_string());
648
649 let helper_id = format!("{}-helper", props.id);
650
651 let focus_ring = if focused() && !is_error {
652 props.color.to_focus_ring()
653 } else {
654 String::new()
655 };
656 let error_ring = if is_error {
657 field_error_style().to_string()
658 } else {
659 String::new()
660 };
661 let valid_ring = if is_valid && !is_error {
662 field_valid_style().to_string()
663 } else {
664 String::new()
665 };
666
667 let input_style = format!(
668 "{} {} {} {} {} {} transition: all 0.2s ease;",
669 base_input_field_style(),
670 props.variant.to_field_style(),
671 props.size.to_input_style(),
672 focus_ring,
673 error_ring,
674 valid_ring
675 );
676
677 let input_style_static: &'static str = Box::leak(input_style.into_boxed_str());
678 let helper_id_str: &'static str = Box::leak(helper_id.clone().into_boxed_str());
679 let helper_id_clone_str: &'static str = helper_id_str;
680
681 let content_str: &'static str = {
682 let s = if let Some(ref msg) = error_msg {
683 msg.clone()
684 } else if is_error {
685 "Invalid value.".to_string()
686 } else if !props.helper_text.is_empty() {
687 props.helper_text.to_string()
688 } else {
689 String::new()
690 };
691 Box::leak(s.into_boxed_str())
692 };
693 let has_helper = !content_str.is_empty();
694 let label_str: &'static str = Box::leak(props.label.to_string().into_boxed_str());
695 let has_label: bool = !props.label.is_empty();
696 let is_focused: bool = focused();
697 let aria_required_str: &'static str = if props.required { "true" } else { "false" };
698 let aria_invalid_str: &'static str = if is_error { "true" } else { "false" };
699
700 rsx! {
701 Control {
702 id: "",
703 input_id: props.id,
704 error: is_error,
705 disabled: props.disabled,
706 focused: is_focused,
707 full_width: props.full_width,
708 required: props.required,
709 variant: props.variant,
710 color: props.color,
711 size: props.size,
712 class: props.class,
713 style: props.style,
714 data_testid: props.data_testid,
715 if has_label {
716 FormLabel {
717 html_for: props.id,
718 error: is_error,
719 focused: is_focused,
720 required: props.required,
721 disabled: props.disabled,
722 "{label_str}"
723 }
724 }
725 Input {
726 r#type: props.r#type,
727 id: props.id,
728 name: props.name,
729 placeholder: props.placeholder,
730 handle: props.handle,
731 valid_handle: props.valid_handle,
732 validate_function: props.validate_function,
733 required: props.required,
734 disabled: props.disabled,
735 pattern: props.pattern,
736 maxlength: props.maxlength,
737 minlength: props.minlength,
738 input_style: input_style_static,
739 aria_describedby: helper_id_clone_str,
740 aria_required: aria_required_str,
741 aria_invalid: aria_invalid_str,
742 otp_mode: true,
743 on_focus: move |_| focused.set(true),
744 on_blur: move |_| focused.set(false),
745 }
746 if has_helper {
747 Helper {
748 id: helper_id_str,
749 error: is_error,
750 valid: is_valid && !is_error,
751 "{content_str}"
752 }
753 }
754 }
755 }
756}
757
758