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