Skip to main content

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, 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
213impl PartialEq for InputProps {
214    fn eq(&self, other: &Self) -> bool {
215        self.r#type == other.r#type
216            && self.label == other.label
217            && self.name == other.name
218            && self.required == other.required
219            && self.error_message == other.error_message
220            && self.input_class == other.input_class
221            && self.field_class == other.field_class
222            && self.label_class == other.label_class
223            && self.class == other.class
224            && self.error_class == other.error_class
225            && self.icon_class == other.icon_class
226            && self.handle == other.handle
227            && self.valid_handle == other.valid_handle
228            && std::ptr::fn_addr_eq(self.validate_function, other.validate_function)
229            && self.eye_active == other.eye_active
230            && self.eye_disabled == other.eye_disabled
231            && self.id == other.id
232            && self.placeholder == other.placeholder
233            && self.aria_label == other.aria_label
234            && self.aria_required == other.aria_required
235            && self.aria_invalid == other.aria_invalid
236            && self.aria_describedby == other.aria_describedby
237            && self.accept == other.accept
238            && self.alt == other.alt
239            && self.autocapitalize == other.autocapitalize
240            && self.autocomplete == other.autocomplete
241            && self.capture == other.capture
242            && self.checked == other.checked
243            && self.dirname == other.dirname
244            && self.disabled == other.disabled
245            && self.form == other.form
246            && self.formaction == other.formaction
247            && self.formenctype == other.formenctype
248            && self.formmethod == other.formmethod
249            && self.formnovalidate == other.formnovalidate
250            && self.formtarget == other.formtarget
251            && self.height == other.height
252            && self.list == other.list
253            && self.max == other.max
254            && self.maxlength == other.maxlength
255            && self.min == other.min
256            && self.minlength == other.minlength
257            && self.multiple == other.multiple
258            && self.pattern == other.pattern
259            && self.readonly == other.readonly
260            && self.size == other.size
261            && self.src == other.src
262            && self.step == other.step
263            && self.value == other.value
264            && self.width == other.width
265    }
266}
267
268/// A custom input component that handles user input and validation.
269///
270/// # Arguments
271/// * `props` - The properties of the component.
272///   - `valid_handle` - A state hook to track the validity of the input.
273///   - `aria_invalid` - A string representing the 'aria-invalid' attribute value for accessibility. Defaults to "true".
274///   - `aria_required` - A string representing the 'aria-required' attribute value for accessibility. Defaults to "true".
275///   - `r#type` - The type of the input element. Defaults to "text".
276///   - `handle` - A state hook to set the value of the input.
277///   - `validate_function` - A function to validate the input value.
278///
279/// # Returns
280/// (Element): A Dioxus element representation of the input component.
281///
282/// # Examples
283/// ```rust
284/// use regex::Regex;
285/// use input_rs::dioxus::Input;
286/// use dioxus::prelude::*;
287///
288/// #[derive(Debug, Default, Clone)]
289/// struct LoginUserSchema {
290///     email: String,
291///     password: String,
292/// }
293///
294/// fn LoginFormOne() -> Element {
295///     let error_handle = use_signal(|| String::new());
296///     let email_valid_handle = use_signal(|| true);
297///     let password_valid_handle = use_signal(|| true);
298///
299///     let email_handle = use_signal(|| String::new());
300///     let password_handle = use_signal(|| String::new());
301///
302///     let validate_email = |email: String| {
303///         let pattern = Regex::new(r"^[^ ]+@[^ ]+\.[a-z]{2,3}$").unwrap();
304///         pattern.is_match(&email)
305///     };
306///     
307///     let validate_password = |password: String| {
308///         !password.is_empty()
309///     };
310///
311///     let onsubmit = {
312///         move |e: FormEvent| {
313///             e.stop_propagation();
314///             // Custom logic for your endpoint goes here.
315///         }
316///     };
317///
318///     rsx! {
319///         div {
320///             class: "form-one-content",
321///             role: "main",
322///             aria_label: "Sign In Form",
323///             div {
324///                 class: "text",
325///                 h2 { "Sign In" }
326///                 if !error_handle().is_empty() {
327///                     div { class: "error", "{error_handle}" }
328///                 }
329///             }
330///             form {
331///                 onsubmit: onsubmit,
332///                 aria_label: "Sign In Form",
333///                 Input {
334///                     r#type: "text",
335///                     handle: email_handle,
336///                     name: "email",
337///                     placeholder: "Email",
338///                     icon_class: "fas fa-user",
339///                     error_message: "Enter a valid email address",
340///                     field_class: "form-one-field",
341///                     error_class: "error-txt",
342///                     required: true,
343///                     valid_handle: email_valid_handle,
344///                     validate_function: validate_email,
345///                 }
346///                 Input {
347///                     r#type: "password",
348///                     handle: password_handle,
349///                     name: "password",
350///                     placeholder: "Password",
351///                     icon_class: "fas fa-lock",
352///                     error_message: "Password can't be blank!",
353///                     field_class: "form-one-field",
354///                     error_class: "error-txt",
355///                     required: true,
356///                     valid_handle: password_valid_handle,
357///                     validate_function: validate_password,
358///                     eye_active: "fa fa-eye",
359///                     eye_disabled: "fa fa-eye-slash",
360///                 }
361///                 div {
362///                     class: "form-one-forgot-pass",
363///                     a {
364///                         href: "#",
365///                         aria_label: "Forgot Password?",
366///                         "Forgot Password?"
367///                     }
368///                 }
369///                 button {
370///                     r#type: "submit",
371///                     "Sign in"
372///                 }
373///                 div {
374///                     class: "sign-up",
375///                     "Not a member? ",
376///                     a {
377///                         href: "#",
378///                         aria_label: "Sign up now",
379///                         "Sign up now"
380///                     }
381///                 }
382///             }
383///         }
384///     }
385/// }
386/// ```
387#[component]
388pub fn Input(mut props: InputProps) -> Element {
389    let mut is_eye_active = use_signal(|| false);
390    let password_type = if is_eye_active() { "text" } else { "password" };
391    let mut country = use_signal(String::default);
392
393    let onchange = {
394        move |e: Event<FormData>| {
395            let value = e.value();
396            props.handle.set(value.clone());
397            props.valid_handle.set((props.validate_function)(value));
398        }
399    };
400
401    let on_select_change = {
402        move |e: Event<FormData>| {
403            let value = e.value();
404            country.set(value.clone());
405            props.handle.set(value);
406        }
407    };
408
409    let on_phone_number_input = {
410        move |e: Event<FormData>| {
411            let input_value = e.value();
412            for (code, _, _, _, _, _) in &COUNTRY_CODES {
413                if code.starts_with(&input_value) {
414                    country.set(input_value);
415                    break;
416                }
417            }
418            // Filter out non-numeric characters
419            let numeric_value: String = e.value().chars().filter(|c| c.is_numeric()).collect();
420            props.handle.set('+'.to_string() + &numeric_value);
421        }
422    };
423
424    let toggle_eye_icon = {
425        move |_| {
426            is_eye_active.set(!is_eye_active());
427        }
428    };
429
430    let input_field = match props.r#type {
431        "password" => rsx! {
432            input {
433                r#type: "{password_type}",
434                class: "{props.input_class}",
435                id: "{props.id}",
436                name: "{props.name}",
437                value: "{props.handle}",
438                placeholder: "{props.placeholder}",
439                aria_label: "{props.aria_label}",
440                aria_required: "{props.aria_required}",
441                aria_invalid: "{props.aria_invalid}",
442                aria_describedby: "{props.aria_describedby}",
443                oninput: onchange,
444                required: props.required,
445                autocomplete: props.autocomplete,
446                autocapitalize: props.autocapitalize,
447                readonly: "{props.readonly}",
448                disabled: "{props.disabled}",
449                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
450                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
451                pattern: "{props.pattern}",
452                size: props.size.map(|v| v.to_string()).unwrap_or_default(),
453            }
454            span {
455                class: if is_eye_active() { props.eye_active } else { props.eye_disabled },
456                onclick: toggle_eye_icon
457            }
458        },
459        "tel" => rsx! {
460            select {
461                style: "max-width: 55px; font-size: 14px; padding: 10px;",
462                onchange: on_select_change,
463                { COUNTRY_CODES.iter().map(|(code, emoji, _, name, _, _)| rsx! {
464                    option { value: "{code}", selected: *code == country(), "{emoji} {name} ({code})" }
465                })}
466            }
467            input {
468                r#type: "tel",
469                class: "{props.input_class}",
470                id: "{props.id}",
471                name: "{props.name}",
472                value: "{props.handle}",
473                placeholder: "{props.placeholder}",
474                aria_label: "{props.aria_label}",
475                aria_required: "{props.aria_required}",
476                aria_invalid: "{props.aria_invalid}",
477                aria_describedby: "{props.aria_describedby}",
478                oninput: on_phone_number_input,
479                required: props.required,
480                autocomplete: props.autocomplete,
481                autocapitalize: props.autocapitalize,
482                readonly: "{props.readonly}",
483                disabled: "{props.disabled}",
484                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
485                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
486                pattern: "{props.pattern}",
487                size: props.size.map(|v| v.to_string()).unwrap_or_default(),
488            }
489        },
490        "textarea" => rsx! {
491            textarea {
492                class: "{props.input_class}",
493                id: "{props.id}",
494                name: "{props.name}",
495                value: "{props.handle}",
496                placeholder: "{props.placeholder}",
497                aria_label: "{props.aria_label}",
498                aria_required: "{props.aria_required}",
499                aria_invalid: "{props.aria_invalid}",
500                aria_describedby: "{props.aria_describedby}",
501                oninput: onchange,
502                required: props.required,
503                readonly: "{props.readonly}",
504                disabled: "{props.disabled}",
505                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
506                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
507            }
508        },
509        _ => rsx! {
510            input {
511                r#type: "{props.r#type}",
512                class: "{props.input_class}",
513                id: "{props.id}",
514                name: "{props.name}",
515                value: "{props.handle}",
516                placeholder: "{props.placeholder}",
517                aria_label: "{props.aria_label}",
518                aria_required: "{props.aria_required}",
519                aria_invalid: "{props.aria_invalid}",
520                aria_describedby: "{props.aria_describedby}",
521                oninput: onchange,
522                required: props.required,
523                autocomplete: props.autocomplete,
524                autocapitalize: props.autocapitalize,
525                readonly: "{props.readonly}",
526                disabled: "{props.disabled}",
527                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
528                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
529                pattern: "{props.pattern}",
530                size: props.size.map(|v| v.to_string()).unwrap_or_default(),
531            }
532        },
533    };
534
535    rsx! {
536        div {
537            class: "{props.class}",
538            label {
539                class: "{props.label_class}",
540                r#for: "{props.id}",
541                "{props.label}"
542            }
543            div {
544                class: "{props.field_class}",
545                {input_field}
546                span {class: "{props.icon_class}" }
547            }
548            if !(props.valid_handle)() {
549                div {
550                    class: "{props.error_class}",
551                    id: "{props.aria_describedby}",
552                    "{props.error_message}"
553                }
554            }
555        }
556    }
557}