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}