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}