input_rs/
dioxus.rs

1use crate::countries::COUNTRY_CODES;
2use dioxus::prelude::*;
3
4/// Props for a custom input component.
5/// This struct includes all possible attributes for an HTML `<input>` element.
6/// See [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input) for more details.
7#[derive(Props, PartialEq, Clone)]
8pub struct InputProps {
9    /// The type of the input, e.g., "text", "password", etc.
10    #[props(default = "text")]
11    pub r#type: &'static str,
12
13    /// The label to be displayed for the input field.
14    #[props(default = "")]
15    pub label: &'static str,
16
17    /// The name of the input field, used for form submission and accessibility.
18    #[props(default = "")]
19    pub name: &'static str,
20
21    /// Indicates whether the input is required or not.
22    #[props(default = false)]
23    pub required: bool,
24
25    /// The error message to display when there is a validation error.
26    #[props(default = "")]
27    pub error_message: &'static str,
28
29    /// The CSS class to be applied to all inner elements.
30    #[props(default = "")]
31    pub input_class: &'static str,
32
33    /// The CSS class to be applied to the inner input element and icon.
34    #[props(default = "")]
35    pub field_class: &'static str,
36
37    /// The CSS class to be applied to the label for the input element.
38    #[props(default = "")]
39    pub label_class: &'static str,
40
41    /// The CSS class to be applied to the input element.
42    #[props(default = "")]
43    pub class: &'static str,
44
45    /// The CSS class to be applied to the error div element.
46    #[props(default = "")]
47    pub error_class: &'static str,
48
49    /// The CSS class to be applied to the icon element.
50    #[props(default = "")]
51    pub icon_class: &'static str,
52
53    /// The state handle for managing the value of the input.
54    pub handle: Signal<String>,
55
56    /// The state handle for managing the validity state of the input.
57    pub valid_handle: Signal<bool>,
58
59    /// A callback function to validate the input value. It takes a `String` as input and returns a `bool`.
60    pub validate_function: fn(String) -> bool,
61
62    /// The icon when the password is visible. Assuming fontawesome icons are used by default.
63    #[props(
64        default = "cursor-pointer right-4 top-1 text-2xl text-gray-600 toggle-button fa fa-eye"
65    )]
66    pub eye_active: &'static str,
67
68    /// The icon when the password is not visible. Assuming fontawesome icons are used by default.
69    #[props(
70        default = "cursor-pointer right-4 top-1 text-2xl text-gray-600 toggle-button fa fa-eye-slash"
71    )]
72    pub eye_disabled: &'static str,
73
74    // Accessibility and SEO-related attributes:
75    /// The ID attribute of the input element.
76    #[props(default = "")]
77    pub id: &'static str,
78
79    /// The placeholder text to be displayed in the input element.
80    #[props(default = "")]
81    pub placeholder: &'static str,
82
83    /// The aria-label attribute for screen readers, providing a label for accessibility.
84    #[props(default = "")]
85    pub aria_label: &'static str,
86
87    /// The aria-required attribute for screen readers, indicating whether the input is required.
88    #[props(default = "true")]
89    pub aria_required: &'static str,
90
91    /// The aria-invalid attribute for screen readers, indicating whether the input value is invalid.
92    #[props(default = "true")]
93    pub aria_invalid: &'static str,
94
95    /// The aria-describedby attribute for screen readers, describing the input element's error message.
96    #[props(default = "")]
97    pub aria_describedby: &'static str,
98
99    // Newly added attributes from MDN:
100    /// Hint for expected file type in file upload controls.
101    #[props(default = "")]
102    pub accept: &'static str,
103
104    /// The alternative text for `<input type="image">`. Required for accessibility.
105    #[props(default = "")]
106    pub alt: &'static str,
107
108    /// Controls automatic capitalization in inputted text.
109    #[props(default = "")]
110    pub autocapitalize: &'static str,
111
112    /// Hint for the browser's autofill feature.
113    #[props(default = "")]
114    pub autocomplete: &'static str,
115
116    /// Media capture input method in file upload controls.
117    #[props(default = "")]
118    pub capture: &'static str,
119
120    /// Whether the control is checked (for checkboxes or radio buttons).
121    #[props(default = false)]
122    pub checked: bool,
123
124    /// Name of the form field to use for sending the element's directionality in form submission.
125    #[props(default = "")]
126    pub dirname: &'static str,
127
128    /// Whether the form control is disabled.
129    #[props(default = false)]
130    pub disabled: bool,
131
132    /// Associates the input with a specific form element.
133    #[props(default = "")]
134    pub form: &'static str,
135
136    /// URL to use for form submission (for `<input type="image" | "submit">`).
137    #[props(default = "")]
138    pub formaction: &'static str,
139
140    /// Form data set encoding type for submission (for `<input type="image" | "submit">`).
141    #[props(default = "")]
142    pub formenctype: &'static str,
143
144    /// HTTP method to use for form submission (for `<input type="image" | "submit">`).
145    #[props(default = "")]
146    pub formmethod: &'static str,
147
148    /// Bypass form validation for submission (for `<input type="image" | "submit">`).
149    #[props(default = false)]
150    pub formnovalidate: bool,
151
152    /// Browsing context for form submission (for `<input type="image" | "submit">`).
153    #[props(default = "")]
154    pub formtarget: &'static str,
155
156    /// Same as the `height` attribute for `<img>` elements.
157    #[props(default = None)]
158    pub height: Option<u32>,
159
160    /// ID of the `<datalist>` element to use for autocomplete suggestions.
161    #[props(default = "")]
162    pub list: &'static str,
163
164    /// The maximum value for date, number, range, etc.
165    #[props(default = "")]
166    pub max: &'static str,
167
168    /// Maximum length of the input value (in characters).
169    #[props(default = None)]
170    pub maxlength: Option<usize>,
171
172    /// The minimum value for date, number, range, etc.
173    #[props(default = "")]
174    pub min: &'static str,
175
176    /// Minimum length of the input value (in characters).
177    #[props(default = None)]
178    pub minlength: Option<usize>,
179
180    /// Boolean indicating whether multiple values are allowed (for file inputs, emails, etc.).
181    #[props(default = false)]
182    pub multiple: bool,
183
184    /// Regex pattern the value must match to be valid.
185    #[props(default = ".*")]
186    pub pattern: &'static str,
187
188    /// Boolean indicating whether the input is read-only.
189    #[props(default = false)]
190    pub readonly: bool,
191
192    /// Size of the input field (e.g., character width).
193    #[props(default = None)]
194    pub size: Option<u32>,
195
196    /// Address of the image resource for `<input type="image">`.
197    #[props(default = "")]
198    pub src: &'static str,
199
200    /// Incremental values that are valid for the input.
201    #[props(default = "")]
202    pub step: &'static str,
203
204    /// The value of the control (used for two-way data binding).
205    #[props(default = "")]
206    pub value: &'static str,
207
208    /// Same as the `width` attribute for `<img>` elements.
209    #[props(default = None)]
210    pub width: Option<u32>,
211}
212
213/// A custom input component that handles user input and validation.
214///
215/// # Arguments
216/// * `props` - The properties of the component.
217///   - `valid_handle` - A state hook to track the validity of the input.
218///   - `aria_invalid` - A string representing the 'aria-invalid' attribute value for accessibility. Defaults to "true".
219///   - `aria_required` - A string representing the 'aria-required' attribute value for accessibility. Defaults to "true".
220///   - `r#type` - The type of the input element. Defaults to "text".
221///   - `handle` - A state hook to set the value of the input.
222///   - `validate_function` - A function to validate the input value.
223///
224/// # Returns
225/// (Element): A Dioxus element representation of the input component.
226///
227/// # Examples
228/// ```rust
229/// use regex::Regex;
230/// use input_rs::dioxus::Input;
231/// use dioxus::prelude::*;
232///
233/// #[derive(Debug, Default, Clone)]
234/// struct LoginUserSchema {
235///     email: String,
236///     password: String,
237/// }
238///
239/// fn LoginFormOne() -> Element {
240///     let error_handle = use_signal(|| String::new());
241///     let email_valid_handle = use_signal(|| true);
242///     let password_valid_handle = use_signal(|| true);
243///
244///     let email_handle = use_signal(|| String::new());
245///     let password_handle = use_signal(|| String::new());
246///
247///     let validate_email = |email: String| {
248///         let pattern = Regex::new(r"^[^ ]+@[^ ]+\.[a-z]{2,3}$").unwrap();
249///         pattern.is_match(&email)
250///     };
251///     
252///     let validate_password = |password: String| {
253///         !password.is_empty()
254///     };
255///
256///     let onsubmit = {
257///         move |e: FormEvent| {
258///             e.stop_propagation();
259///             // Custom logic for your endpoint goes here.
260///         }
261///     };
262///
263///     rsx! {
264///         div {
265///             class: "form-one-content",
266///             role: "main",
267///             aria_label: "Sign In Form",
268///             div {
269///                 class: "text",
270///                 h2 { "Sign In" }
271///                 if !error_handle().is_empty() {
272///                     div { class: "error", "{error_handle}" }
273///                 }
274///             }
275///             form {
276///                 onsubmit: onsubmit,
277///                 aria_label: "Sign In Form",
278///                 Input {
279///                     r#type: "text",
280///                     handle: email_handle,
281///                     name: "email",
282///                     placeholder: "Email",
283///                     icon_class: "fas fa-user",
284///                     error_message: "Enter a valid email address",
285///                     field_class: "form-one-field",
286///                     error_class: "error-txt",
287///                     required: true,
288///                     valid_handle: email_valid_handle,
289///                     validate_function: validate_email,
290///                 }
291///                 Input {
292///                     r#type: "password",
293///                     handle: password_handle,
294///                     name: "password",
295///                     placeholder: "Password",
296///                     icon_class: "fas fa-lock",
297///                     error_message: "Password can't be blank!",
298///                     field_class: "form-one-field",
299///                     error_class: "error-txt",
300///                     required: true,
301///                     valid_handle: password_valid_handle,
302///                     validate_function: validate_password,
303///                     eye_active: "fa fa-eye",
304///                     eye_disabled: "fa fa-eye-slash",
305///                 }
306///                 div {
307///                     class: "form-one-forgot-pass",
308///                     a {
309///                         href: "#",
310///                         aria_label: "Forgot Password?",
311///                         "Forgot Password?"
312///                     }
313///                 }
314///                 button {
315///                     r#type: "submit",
316///                     "Sign in"
317///                 }
318///                 div {
319///                     class: "sign-up",
320///                     "Not a member? ",
321///                     a {
322///                         href: "#",
323///                         aria_label: "Sign up now",
324///                         "Sign up now"
325///                     }
326///                 }
327///             }
328///         }
329///     }
330/// }
331/// ```
332#[component]
333pub fn Input(mut props: InputProps) -> Element {
334    let mut is_eye_active = use_signal(|| false);
335    let password_type = if is_eye_active() { "text" } else { "password" };
336    let mut country = use_signal(String::default);
337
338    let onchange = {
339        move |e: Event<FormData>| {
340            let value = e.value();
341            props.handle.set(value.clone());
342            props.valid_handle.set((props.validate_function)(value));
343        }
344    };
345
346    let on_select_change = {
347        move |e: Event<FormData>| {
348            let value = e.value();
349            country.set(value.clone());
350            props.handle.set(value);
351        }
352    };
353
354    let on_phone_number_input = {
355        move |e: Event<FormData>| {
356            let input_value = e.value();
357            for (code, _, _, _, _, _) in &COUNTRY_CODES {
358                if code.starts_with(&input_value) {
359                    country.set(input_value);
360                    break;
361                }
362            }
363            // Filter out non-numeric characters
364            let numeric_value: String = e.value().chars().filter(|c| c.is_numeric()).collect();
365            props.handle.set('+'.to_string() + &numeric_value);
366        }
367    };
368
369    let toggle_eye_icon = {
370        move |_| {
371            is_eye_active.set(!is_eye_active());
372        }
373    };
374
375    let input_field = match props.r#type {
376        "password" => rsx! {
377            input {
378                r#type: "{password_type}",
379                class: "{props.input_class}",
380                id: "{props.id}",
381                name: "{props.name}",
382                value: "{props.handle}",
383                placeholder: "{props.placeholder}",
384                aria_label: "{props.aria_label}",
385                aria_required: "{props.aria_required}",
386                aria_invalid: "{props.aria_invalid}",
387                aria_describedby: "{props.aria_describedby}",
388                oninput: onchange,
389                required: props.required,
390                autocomplete: props.autocomplete,
391                autocapitalize: props.autocapitalize,
392                readonly: "{props.readonly}",
393                disabled: "{props.disabled}",
394                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
395                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
396                pattern: "{props.pattern}",
397                size: props.size.map(|v| v.to_string()).unwrap_or_default(),
398            }
399            span {
400                class: if is_eye_active() { props.eye_active } else { props.eye_disabled },
401                onclick: toggle_eye_icon
402            }
403        },
404        "tel" => rsx! {
405            select {
406                style: "max-width: 55px; font-size: 14px; padding: 10px;",
407                onchange: on_select_change,
408                { COUNTRY_CODES.iter().map(|(code, emoji, _, name, _, _)| rsx! {
409                    option { value: "{code}", selected: *code == country(), "{emoji} {name} ({code})" }
410                })}
411            }
412            input {
413                r#type: "tel",
414                class: "{props.input_class}",
415                id: "{props.id}",
416                name: "{props.name}",
417                value: "{props.handle}",
418                placeholder: "{props.placeholder}",
419                aria_label: "{props.aria_label}",
420                aria_required: "{props.aria_required}",
421                aria_invalid: "{props.aria_invalid}",
422                aria_describedby: "{props.aria_describedby}",
423                oninput: on_phone_number_input,
424                required: props.required,
425                autocomplete: props.autocomplete,
426                autocapitalize: props.autocapitalize,
427                readonly: "{props.readonly}",
428                disabled: "{props.disabled}",
429                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
430                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
431                pattern: "{props.pattern}",
432                size: props.size.map(|v| v.to_string()).unwrap_or_default(),
433            }
434        },
435        "textarea" => rsx! {
436            textarea {
437                class: "{props.input_class}",
438                id: "{props.id}",
439                name: "{props.name}",
440                value: "{props.handle}",
441                placeholder: "{props.placeholder}",
442                aria_label: "{props.aria_label}",
443                aria_required: "{props.aria_required}",
444                aria_invalid: "{props.aria_invalid}",
445                aria_describedby: "{props.aria_describedby}",
446                oninput: onchange,
447                required: props.required,
448                readonly: "{props.readonly}",
449                disabled: "{props.disabled}",
450                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
451                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
452            }
453        },
454        _ => rsx! {
455            input {
456                r#type: "{props.r#type}",
457                class: "{props.input_class}",
458                id: "{props.id}",
459                name: "{props.name}",
460                value: "{props.handle}",
461                placeholder: "{props.placeholder}",
462                aria_label: "{props.aria_label}",
463                aria_required: "{props.aria_required}",
464                aria_invalid: "{props.aria_invalid}",
465                aria_describedby: "{props.aria_describedby}",
466                oninput: onchange,
467                required: props.required,
468                autocomplete: props.autocomplete,
469                autocapitalize: props.autocapitalize,
470                readonly: "{props.readonly}",
471                disabled: "{props.disabled}",
472                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
473                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
474                pattern: "{props.pattern}",
475                size: props.size.map(|v| v.to_string()).unwrap_or_default(),
476            }
477        },
478    };
479
480    rsx! {
481        div {
482            class: "{props.class}",
483            label {
484                class: "{props.label_class}",
485                r#for: "{props.id}",
486                "{props.label}"
487            }
488            div {
489                class: "{props.field_class}",
490                {input_field}
491                span {class: "{props.icon_class}" }
492            }
493            if !(props.valid_handle)() {
494                div {
495                    class: "{props.error_class}",
496                    id: "{props.aria_describedby}",
497                    "{props.error_message}"
498                }
499            }
500        }
501    }
502}