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