Skip to main content

input_rs/
yew.rs

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