Skip to main content

dioxus_ui_system/molecules/
input_group.rs

1//! Input Group molecule component
2//!
3//! Combines Label, Input, and helper text/error messages into a cohesive form field.
4
5use dioxus::prelude::*;
6use crate::atoms::{Input, InputType, Label, TextSize, TextColor};
7use crate::theme::use_style;
8use crate::styles::Style;
9
10/// Input Group properties
11#[derive(Props, Clone, PartialEq)]
12pub struct InputGroupProps {
13    /// Field label
14    pub label: String,
15    /// Current input value
16    #[props(default)]
17    pub value: String,
18    /// Placeholder text
19    #[props(default)]
20    pub placeholder: Option<String>,
21    /// Input type
22    #[props(default)]
23    pub input_type: InputType,
24    /// Error message (shows error state when Some)
25    #[props(default)]
26    pub error: Option<String>,
27    /// Help/hint text
28    #[props(default)]
29    pub hint: Option<String>,
30    /// Required field indicator
31    #[props(default)]
32    pub required: bool,
33    /// Disabled state
34    #[props(default)]
35    pub disabled: bool,
36    /// Change handler
37    #[props(default)]
38    pub onchange: Option<EventHandler<String>>,
39    /// Optional icon before input
40    #[props(default)]
41    pub leading_icon: Option<Element>,
42    /// Optional icon after input
43    #[props(default)]
44    pub trailing_icon: Option<Element>,
45    /// Custom inline styles
46    #[props(default)]
47    pub style: Option<String>,
48}
49
50/// Input Group molecule component
51///
52/// # Example
53/// ```rust,ignore
54/// use dioxus_ui_system::molecules::InputGroup;
55///
56/// let mut email = use_signal(|| String::new());
57///
58/// rsx! {
59///     InputGroup {
60///         label: "Email",
61///         value: email(),
62///         placeholder: "you@example.com",
63///         hint: "We'll never share your email.",
64///         onchange: move |v| email.set(v),
65///     }
66/// }
67/// ```
68#[component]
69pub fn InputGroup(props: InputGroupProps) -> Element {
70    let label = props.label.clone();
71    let value = props.value.clone();
72    let placeholder = props.placeholder.clone();
73    let input_type = props.input_type.clone();
74    let error = props.error.clone();
75    let hint = props.hint.clone();
76    let required = props.required;
77    let disabled = props.disabled;
78    
79    let container_style = use_style(|t| {
80        Style::new()
81            .flex()
82            .flex_col()
83            .gap(&t.spacing, "xs")
84            .w_full()
85            .build()
86    });
87    
88    let input_wrapper_style = use_style(|_| {
89        Style::new()
90            .flex()
91            .items_center()
92            .relative()
93            .build()
94    });
95    
96    let final_style = if let Some(custom) = &props.style {
97        format!("{} {}", container_style(), custom)
98    } else {
99        container_style()
100    };
101    
102    let label_element = if required {
103        rsx! {
104            Label {
105                size: TextSize::Small,
106                weight: crate::atoms::label::TextWeight::Medium,
107                "{label}"
108                Label {
109                    size: TextSize::Small,
110                    color: TextColor::Destructive,
111                    " *"
112                }
113            }
114        }
115    } else {
116        rsx! {
117            Label {
118                size: TextSize::Small,
119                weight: crate::atoms::label::TextWeight::Medium,
120                "{label}"
121            }
122        }
123    };
124    
125    let helper_text = if let Some(err) = error {
126        Some(rsx! {
127            Label {
128                size: TextSize::ExtraSmall,
129                color: TextColor::Destructive,
130                "{err}"
131            }
132        })
133    } else if let Some(h) = hint {
134        Some(rsx! {
135            Label {
136                size: TextSize::ExtraSmall,
137                color: TextColor::Muted,
138                "{h}"
139            }
140        })
141    } else {
142        None
143    };
144    
145    rsx! {
146        div {
147            style: "{final_style}",
148            {label_element}
149            
150            div {
151                style: "{input_wrapper_style}",
152                
153                if props.leading_icon.is_some() {
154                    div {
155                        style: "position: absolute; left: 12px; z-index: 1;",
156                        {props.leading_icon.unwrap()}
157                    }
158                }
159                
160                Input {
161                    value: value,
162                    placeholder: placeholder,
163                    input_type: input_type,
164                    disabled: disabled,
165                    onchange: props.onchange.clone(),
166                }
167                
168                if props.trailing_icon.is_some() {
169                    div {
170                        style: "position: absolute; right: 12px; z-index: 1;",
171                        {props.trailing_icon.unwrap()}
172                    }
173                }
174            }
175            
176            {helper_text}
177        }
178    }
179}