Skip to main content

input_rs/
leptos.rs

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