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 inline CSS style to apply directly to the bare `<input>` element.
34    #[props(default = "")]
35    pub input_style: &'static str,
36
37    /// The CSS class to be applied to the inner input element and icon.
38    #[props(default = "")]
39    pub field_class: &'static str,
40
41    /// The CSS class to be applied to the label for the input element.
42    #[props(default = "")]
43    pub label_class: &'static str,
44
45    /// The CSS class to be applied to the input element.
46    #[props(default = "")]
47    pub class: &'static str,
48
49    /// The CSS class to be applied to the error div element.
50    #[props(default = "")]
51    pub error_class: &'static str,
52
53    /// The CSS class to be applied to the icon element.
54    #[props(default = "")]
55    pub icon_class: &'static str,
56
57    /// The state handle for managing the value of the input.
58    pub handle: Signal<String>,
59
60    /// The state handle for managing the validity state of the input.
61    pub valid_handle: Signal<bool>,
62
63    /// A callback function to validate the input value. It takes a `String` as input and returns a `bool`.
64    pub validate_function: fn(String) -> bool,
65
66    /// The icon when the password is visible. Assuming fontawesome icons are used by default.
67    #[props(
68        default = "cursor-pointer right-4 top-1 text-2xl text-gray-600 toggle-button fa fa-eye"
69    )]
70    pub eye_active: &'static str,
71
72    /// The icon when the password is not visible. Assuming fontawesome icons are used by default.
73    #[props(
74        default = "cursor-pointer right-4 top-1 text-2xl text-gray-600 toggle-button fa fa-eye-slash"
75    )]
76    pub eye_disabled: &'static str,
77
78    // Accessibility and SEO-related attributes:
79    /// The ID attribute of the input element.
80    #[props(default = "")]
81    pub id: &'static str,
82
83    /// The placeholder text to be displayed in the input element.
84    #[props(default = "")]
85    pub placeholder: &'static str,
86
87    /// The aria-label attribute for screen readers, providing a label for accessibility.
88    #[props(default = "")]
89    pub aria_label: &'static str,
90
91    /// The aria-required attribute for screen readers, indicating whether the input is required.
92    #[props(default = "true")]
93    pub aria_required: &'static str,
94
95    /// The aria-invalid attribute for screen readers, indicating whether the input value is invalid.
96    #[props(default = "true")]
97    pub aria_invalid: &'static str,
98
99    /// The aria-describedby attribute for screen readers, describing the input element's error message.
100    #[props(default = "")]
101    pub aria_describedby: &'static str,
102
103    // Newly added attributes from MDN:
104    /// Hint for expected file type in file upload controls.
105    #[props(default = "")]
106    pub accept: &'static str,
107
108    /// The alternative text for `<input type="image">`. Required for accessibility.
109    #[props(default = "")]
110    pub alt: &'static str,
111
112    /// Controls automatic capitalization in inputted text.
113    #[props(default = "")]
114    pub autocapitalize: &'static str,
115
116    /// Hint for the browser's autofill feature.
117    #[props(default = "")]
118    pub autocomplete: &'static str,
119
120    /// Media capture input method in file upload controls.
121    #[props(default = "")]
122    pub capture: &'static str,
123
124    /// Whether the control is checked (for checkboxes or radio buttons).
125    #[props(default = false)]
126    pub checked: bool,
127
128    /// Name of the form field to use for sending the element's directionality in form submission.
129    #[props(default = "")]
130    pub dirname: &'static str,
131
132    /// Whether the form control is disabled.
133    #[props(default = false)]
134    pub disabled: bool,
135
136    /// Associates the input with a specific form element.
137    #[props(default = "")]
138    pub form: &'static str,
139
140    /// URL to use for form submission (for `<input type="image" | "submit">`).
141    #[props(default = "")]
142    pub formaction: &'static str,
143
144    /// Form data set encoding type for submission (for `<input type="image" | "submit">`).
145    #[props(default = "")]
146    pub formenctype: &'static str,
147
148    /// HTTP method to use for form submission (for `<input type="image" | "submit">`).
149    #[props(default = "")]
150    pub formmethod: &'static str,
151
152    /// Bypass form validation for submission (for `<input type="image" | "submit">`).
153    #[props(default = false)]
154    pub formnovalidate: bool,
155
156    /// Browsing context for form submission (for `<input type="image" | "submit">`).
157    #[props(default = "")]
158    pub formtarget: &'static str,
159
160    /// Same as the `height` attribute for `<img>` elements.
161    #[props(default = None)]
162    pub height: Option<u32>,
163
164    /// ID of the `<datalist>` element to use for autocomplete suggestions.
165    #[props(default = "")]
166    pub list: &'static str,
167
168    /// The maximum value for date, number, range, etc.
169    #[props(default = "")]
170    pub max: &'static str,
171
172    /// Maximum length of the input value (in characters).
173    #[props(default = None)]
174    pub maxlength: Option<usize>,
175
176    /// The minimum value for date, number, range, etc.
177    #[props(default = "")]
178    pub min: &'static str,
179
180    /// Minimum length of the input value (in characters).
181    #[props(default = None)]
182    pub minlength: Option<usize>,
183
184    /// Boolean indicating whether multiple values are allowed (for file inputs, emails, etc.).
185    #[props(default = false)]
186    pub multiple: bool,
187
188    /// Regex pattern the value must match to be valid.
189    #[props(default = ".*")]
190    pub pattern: &'static str,
191
192    /// Boolean indicating whether the input is read-only.
193    #[props(default = false)]
194    pub readonly: bool,
195
196    /// Size of the input field (e.g., character width).
197    #[props(default = None)]
198    pub size: Option<u32>,
199
200    /// Address of the image resource for `<input type="image">`.
201    #[props(default = "")]
202    pub src: &'static str,
203
204    /// Incremental values that are valid for the input.
205    #[props(default = "")]
206    pub step: &'static str,
207
208    /// The value of the control (used for two-way data binding).
209    #[props(default = "")]
210    pub value: &'static str,
211
212    /// Same as the `width` attribute for `<img>` elements.
213    #[props(default = None)]
214    pub width: Option<u32>,
215
216    /// When `true`, bypasses wrapper divs and renders only the bare `<input>`.
217    /// Intended for composable slot components such as OTP inputs.
218    #[props(default = false)]
219    pub otp_mode: bool,
220
221    /// Callback fired when the input gains focus. Active only in `otp_mode`.
222    #[props(default)]
223    pub on_focus: Option<EventHandler<FocusEvent>>,
224
225    /// Callback fired when the input loses focus. Active only in `otp_mode`.
226    #[props(default)]
227    pub on_blur: Option<EventHandler<FocusEvent>>,
228
229    /// Raw `oninput` event handler, active only in `otp_mode`.
230    /// When provided, this fires **instead of** the default `handle`-setting `onchange`,
231    /// so the caller has full control over value clearing and slot notification.
232    #[props(default)]
233    pub on_input: Option<EventHandler<Event<FormData>>>,
234
235    /// `inputmode` attribute, hints the virtual keyboard type (e.g. `"numeric"`).
236    #[props(default = "")]
237    pub inputmode: &'static str,
238}
239
240impl PartialEq for InputProps {
241    fn eq(&self, other: &Self) -> bool {
242        self.r#type == other.r#type
243            && self.label == other.label
244            && self.name == other.name
245            && self.required == other.required
246            && self.error_message == other.error_message
247            && self.input_class == other.input_class
248            && self.field_class == other.field_class
249            && self.label_class == other.label_class
250            && self.class == other.class
251            && self.error_class == other.error_class
252            && self.icon_class == other.icon_class
253            && self.handle == other.handle
254            && self.valid_handle == other.valid_handle
255            && std::ptr::fn_addr_eq(self.validate_function, other.validate_function)
256            && self.eye_active == other.eye_active
257            && self.eye_disabled == other.eye_disabled
258            && self.id == other.id
259            && self.placeholder == other.placeholder
260            && self.aria_label == other.aria_label
261            && self.aria_required == other.aria_required
262            && self.aria_invalid == other.aria_invalid
263            && self.aria_describedby == other.aria_describedby
264            && self.accept == other.accept
265            && self.alt == other.alt
266            && self.autocapitalize == other.autocapitalize
267            && self.autocomplete == other.autocomplete
268            && self.capture == other.capture
269            && self.checked == other.checked
270            && self.dirname == other.dirname
271            && self.disabled == other.disabled
272            && self.form == other.form
273            && self.formaction == other.formaction
274            && self.formenctype == other.formenctype
275            && self.formmethod == other.formmethod
276            && self.formnovalidate == other.formnovalidate
277            && self.formtarget == other.formtarget
278            && self.height == other.height
279            && self.list == other.list
280            && self.max == other.max
281            && self.maxlength == other.maxlength
282            && self.min == other.min
283            && self.minlength == other.minlength
284            && self.multiple == other.multiple
285            && self.pattern == other.pattern
286            && self.readonly == other.readonly
287            && self.size == other.size
288            && self.src == other.src
289            && self.step == other.step
290            && self.value == other.value
291            && self.width == other.width
292            && self.inputmode == other.inputmode
293    }
294}
295
296/// A custom input component that handles user input and validation.
297///
298/// # Arguments
299/// * `props` - The properties of the component.
300///   - `valid_handle` - A state hook to track the validity of the input.
301///   - `aria_invalid` - A string representing the 'aria-invalid' attribute value for accessibility. Defaults to "true".
302///   - `aria_required` - A string representing the 'aria-required' attribute value for accessibility. Defaults to "true".
303///   - `r#type` - The type of the input element. Defaults to "text".
304///   - `handle` - A state hook to set the value of the input.
305///   - `validate_function` - A function to validate the input value.
306///
307/// # Returns
308/// (Element): A Dioxus element representation of the input component.
309///
310/// # Examples
311/// ```rust
312/// use regex::Regex;
313/// use input_rs::dioxus::Input;
314/// use dioxus::prelude::*;
315///
316/// #[derive(Debug, Default, Clone)]
317/// struct LoginUserSchema {
318///     email: String,
319///     password: String,
320/// }
321///
322/// fn LoginFormOne() -> Element {
323///     let error_handle = use_signal(|| String::new());
324///     let email_valid_handle = use_signal(|| true);
325///     let password_valid_handle = use_signal(|| true);
326///
327///     let email_handle = use_signal(|| String::new());
328///     let password_handle = use_signal(|| String::new());
329///
330///     let validate_email = |email: String| {
331///         let pattern = Regex::new(r"^[^ ]+@[^ ]+\.[a-z]{2,3}$").unwrap();
332///         pattern.is_match(&email)
333///     };
334///     
335///     let validate_password = |password: String| {
336///         !password.is_empty()
337///     };
338///
339///     let onsubmit = {
340///         move |e: FormEvent| {
341///             e.stop_propagation();
342///             // Custom logic for your endpoint goes here.
343///         }
344///     };
345///
346///     rsx! {
347///         div {
348///             class: "form-one-content",
349///             role: "main",
350///             aria_label: "Sign In Form",
351///             div {
352///                 class: "text",
353///                 h2 { "Sign In" }
354///                 if !error_handle().is_empty() {
355///                     div { class: "error", "{error_handle}" }
356///                 }
357///             }
358///             form {
359///                 onsubmit: onsubmit,
360///                 aria_label: "Sign In Form",
361///                 Input {
362///                     r#type: "text",
363///                     handle: email_handle,
364///                     name: "email",
365///                     placeholder: "Email",
366///                     icon_class: "fas fa-user",
367///                     error_message: "Enter a valid email address",
368///                     field_class: "form-one-field",
369///                     error_class: "error-txt",
370///                     required: true,
371///                     valid_handle: email_valid_handle,
372///                     validate_function: validate_email,
373///                 }
374///                 Input {
375///                     r#type: "password",
376///                     handle: password_handle,
377///                     name: "password",
378///                     placeholder: "Password",
379///                     icon_class: "fas fa-lock",
380///                     error_message: "Password can't be blank!",
381///                     field_class: "form-one-field",
382///                     error_class: "error-txt",
383///                     required: true,
384///                     valid_handle: password_valid_handle,
385///                     validate_function: validate_password,
386///                     eye_active: "fa fa-eye",
387///                     eye_disabled: "fa fa-eye-slash",
388///                 }
389///                 div {
390///                     class: "form-one-forgot-pass",
391///                     a {
392///                         href: "#",
393///                         aria_label: "Forgot Password?",
394///                         "Forgot Password?"
395///                     }
396///                 }
397///                 button {
398///                     r#type: "submit",
399///                     "Sign in"
400///                 }
401///                 div {
402///                     class: "sign-up",
403///                     "Not a member? ",
404///                     a {
405///                         href: "#",
406///                         aria_label: "Sign up now",
407///                         "Sign up now"
408///                     }
409///                 }
410///             }
411///         }
412///     }
413/// }
414/// ```
415#[component]
416pub fn Input(mut props: InputProps) -> Element {
417    let mut is_eye_active = use_signal(|| false);
418    let password_type = if is_eye_active() { "text" } else { "password" };
419    let mut country = use_signal(String::default);
420
421    let mut onchange = {
422        move |e: Event<FormData>| {
423            let value = e.value();
424            props.handle.set(value.clone());
425            props.valid_handle.set((props.validate_function)(value));
426        }
427    };
428
429    let on_select_change = {
430        move |e: Event<FormData>| {
431            let value = e.value();
432            country.set(value.clone());
433            props.handle.set(value);
434        }
435    };
436
437    let on_phone_number_input = {
438        move |e: Event<FormData>| {
439            let input_value = e.value();
440            for (code, _, _, _, _, _) in &COUNTRY_CODES {
441                if code.starts_with(&input_value) {
442                    country.set(input_value);
443                    break;
444                }
445            }
446            // Filter out non-numeric characters
447            let numeric_value: String = e.value().chars().filter(|c| c.is_numeric()).collect();
448            props.handle.set('+'.to_string() + &numeric_value);
449        }
450    };
451
452    let toggle_eye_icon = {
453        move |_| {
454            is_eye_active.set(!is_eye_active());
455        }
456    };
457
458    let input_field = match props.r#type {
459        "password" => rsx! {
460            input {
461                r#type: "{password_type}",
462                class: "{props.input_class}",
463                id: "{props.id}",
464                name: "{props.name}",
465                value: "{props.handle}",
466                placeholder: "{props.placeholder}",
467                aria_label: "{props.aria_label}",
468                aria_required: "{props.aria_required}",
469                aria_invalid: "{props.aria_invalid}",
470                aria_describedby: "{props.aria_describedby}",
471                oninput: onchange,
472                required: props.required,
473                autocomplete: props.autocomplete,
474                autocapitalize: props.autocapitalize,
475                readonly: "{props.readonly}",
476                disabled: "{props.disabled}",
477                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
478                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
479                pattern: "{props.pattern}",
480                size: props.size.map(|v| v.to_string()).unwrap_or_default(),
481            }
482            span {
483                class: if is_eye_active() { props.eye_active } else { props.eye_disabled },
484                onclick: toggle_eye_icon
485            }
486        },
487        "tel" => rsx! {
488            select {
489                style: "max-width: 55px; font-size: 14px; padding: 10px;",
490                onchange: on_select_change,
491                { COUNTRY_CODES.iter().map(|(code, emoji, _, name, _, _)| rsx! {
492                    option { value: "{code}", selected: *code == country(), "{emoji} {name} ({code})" }
493                })}
494            }
495            input {
496                r#type: "tel",
497                class: "{props.input_class}",
498                id: "{props.id}",
499                name: "{props.name}",
500                value: "{props.handle}",
501                placeholder: "{props.placeholder}",
502                aria_label: "{props.aria_label}",
503                aria_required: "{props.aria_required}",
504                aria_invalid: "{props.aria_invalid}",
505                aria_describedby: "{props.aria_describedby}",
506                oninput: on_phone_number_input,
507                required: props.required,
508                autocomplete: props.autocomplete,
509                autocapitalize: props.autocapitalize,
510                readonly: "{props.readonly}",
511                disabled: "{props.disabled}",
512                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
513                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
514                pattern: "{props.pattern}",
515                size: props.size.map(|v| v.to_string()).unwrap_or_default(),
516            }
517        },
518        "textarea" => rsx! {
519            textarea {
520                class: "{props.input_class}",
521                id: "{props.id}",
522                name: "{props.name}",
523                value: "{props.handle}",
524                placeholder: "{props.placeholder}",
525                aria_label: "{props.aria_label}",
526                aria_required: "{props.aria_required}",
527                aria_invalid: "{props.aria_invalid}",
528                aria_describedby: "{props.aria_describedby}",
529                oninput: onchange,
530                required: props.required,
531                readonly: "{props.readonly}",
532                disabled: "{props.disabled}",
533                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
534                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
535            }
536        },
537        _ => rsx! {
538            input {
539                r#type: "{props.r#type}",
540                class: "{props.input_class}",
541                id: "{props.id}",
542                name: "{props.name}",
543                value: "{props.handle}",
544                placeholder: "{props.placeholder}",
545                aria_label: "{props.aria_label}",
546                aria_required: "{props.aria_required}",
547                aria_invalid: "{props.aria_invalid}",
548                aria_describedby: "{props.aria_describedby}",
549                oninput: onchange,
550                required: props.required,
551                autocomplete: props.autocomplete,
552                autocapitalize: props.autocapitalize,
553                readonly: "{props.readonly}",
554                disabled: "{props.disabled}",
555                minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
556                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
557                pattern: "{props.pattern}",
558                size: props.size.map(|v| v.to_string()).unwrap_or_default(),
559            }
560        },
561    };
562
563    if props.otp_mode {
564        let otp_oninput = {
565            let on_input_opt = props.on_input;
566            move |e: Event<FormData>| {
567                if let Some(ref h) = on_input_opt {
568                    h.call(e);
569                } else {
570                    onchange(e);
571                }
572            }
573        };
574        return rsx! {
575            input {
576                r#type: "{props.r#type}",
577                class: "{props.input_class}",
578                style: "{props.input_style}",
579                id: "{props.id}",
580                name: "{props.name}",
581                value: "{props.handle}",
582                placeholder: "{props.placeholder}",
583                aria_label: "{props.aria_label}",
584                aria_required: "{props.aria_required}",
585                aria_invalid: "{props.aria_invalid}",
586                aria_describedby: "{props.aria_describedby}",
587                autocomplete: props.autocomplete,
588                inputmode: props.inputmode,
589                maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
590                pattern: "{props.pattern}",
591                disabled: "{props.disabled}",
592                required: props.required,
593                oninput: otp_oninput,
594                onfocus: move |e| { if let Some(h) = &props.on_focus { h.call(e); } },
595                onblur: move |e| { if let Some(h) = &props.on_blur { h.call(e); } },
596            }
597        };
598    }
599
600    rsx! {
601        div {
602            class: "{props.class}",
603            label {
604                class: "{props.label_class}",
605                r#for: "{props.id}",
606                "{props.label}"
607            }
608            div {
609                class: "{props.field_class}",
610                {input_field}
611                span {class: "{props.icon_class}" }
612            }
613            if !(props.valid_handle)() {
614                div {
615                    class: "{props.error_class}",
616                    id: "{props.aria_describedby}",
617                    "{props.error_message}"
618                }
619            }
620        }
621    }
622}