Skip to main content

form_rs/
dioxus.rs

1// Copyright 2026 Open SASS Core Maintainers.
2//
3// Licensed under the MIT license
4// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
5// option. This file may not be copied, modified, or distributed
6// except according to those terms.
7
8#![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/// Shared context propagated by [`Form`] to all descendant components.
21#[derive(Clone, Debug, PartialEq)]
22pub struct FormContext {
23    /// Active validation strategy for the entire form.
24    pub validation_behavior: ValidationBehavior,
25    /// `true` when the form is actively being submitted.
26    pub is_submitting: bool,
27    /// Whether native browser validation is suppressed.
28    pub novalidate: bool,
29}
30
31/// Shared context provided by [`Control`] to its children.
32#[derive(Clone, Debug, PartialEq)]
33pub struct ControlContext {
34    /// Whether the field is in an error state.
35    pub error: bool,
36    /// Whether the field is disabled.
37    pub disabled: bool,
38    /// Whether the field is focused.
39    pub focused: bool,
40    /// Whether the field is required.
41    pub required: bool,
42    /// Whether the field has a non-empty value.
43    pub filled: bool,
44    /// Visual variant of the field.
45    pub variant: Variant,
46    /// Color theme of the field.
47    pub color: Color,
48    /// Size of the field.
49    pub size: Size,
50    /// ID of the underlying `<input>` element, used to link `<label>`.
51    pub input_id: &'static str,
52}
53
54/// Props for the [`Form`] Dioxus component.
55#[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/// A semantic `<form>` wrapper that provides validation context to descendant fields.
94///
95/// Propagates [`FormContext`] via Dioxus context so that [`Control`],
96/// [`FormLabel`], and [`Helper`] can read validation mode and submission
97/// state without prop drilling.
98///
99/// # Accessibility
100///
101/// - Renders as a native `<form>` element (implicit `"form"` ARIA role).
102/// - Supply `aria_label` or `aria_labelledby` to create a named landmark.
103#[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/// Props for the [`Control`] Dioxus component.
156#[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/// A context provider that wraps a single form field with its label and helper text.
193///
194/// Provides [`ControlContext`] to [`FormLabel`] and [`Helper`]
195/// descendants so they reflect error, disabled, required, variant, and color
196/// states automatically.
197#[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/// Props for the [`FormLabel`] Dioxus component.
258#[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/// Renders an accessible `<label>` linked to a form field.
287///
288/// When nested inside [`Control`], it automatically reads `error`,
289/// `disabled`, `focused`, `required`, and `color` from [`ControlContext`].
290#[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/// Props for the [`Helper`] Dioxus component.
357#[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/// Renders accessible helper text beneath a form field.
384///
385/// When nested inside [`Control`], it inherits `error` and `disabled`
386/// states from [`ControlContext`]. Error text is announced via `role="alert"`.
387#[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/// Props for the [`Group`] Dioxus component.
446#[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/// Groups checkboxes or switch controls with optional horizontal layout.
469///
470/// Renders as `<div role="group">` with `aria_label` or `aria_labelledby`
471/// to name the group for screen readers.
472#[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/// Props for the [`ControlLabel`] Dioxus component.
507#[derive(Props, Clone, PartialEq)]
508pub struct ControlLabelProps {
509    /// The control element, e.g. an `<input type="checkbox">`.
510    pub control: Element,
511    /// The label text or content.
512    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/// A label wrapper that pairs a control with its descriptive text.
534///
535/// Renders a `<label>` element containing both the control and its text,
536/// with configurable label placement (start, end, top, bottom).
537#[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/// Props for the [`Field`] Dioxus component.
586#[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/// A convenience composition of [`Control`], [`FormLabel`], [`Input`], and
632/// [`Helper`] into a single validated form field for Dioxus.
633///
634/// Uses `input-rs` for the underlying `<input>` element with HTML5 validation.
635#[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// Copyright 2026 Open SASS Core Maintainers.
759//
760// Licensed under the MIT license
761// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
762// option. This file may not be copied, modified, or distributed
763// except according to those terms.