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}