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, PartialEq, 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 CSS class to be applied to the inner input element and icon.
34 #[props(default = "")]
35 pub field_class: &'static str,
36
37 /// The CSS class to be applied to the label for the input element.
38 #[props(default = "")]
39 pub label_class: &'static str,
40
41 /// The CSS class to be applied to the input element.
42 #[props(default = "")]
43 pub class: &'static str,
44
45 /// The CSS class to be applied to the error div element.
46 #[props(default = "")]
47 pub error_class: &'static str,
48
49 /// The CSS class to be applied to the icon element.
50 #[props(default = "")]
51 pub icon_class: &'static str,
52
53 /// The state handle for managing the value of the input.
54 pub handle: Signal<String>,
55
56 /// The state handle for managing the validity state of the input.
57 pub valid_handle: Signal<bool>,
58
59 /// A callback function to validate the input value. It takes a `String` as input and returns a `bool`.
60 pub validate_function: fn(String) -> bool,
61
62 /// The icon when the password is visible. Assuming fontawesome icons are used by default.
63 #[props(
64 default = "cursor-pointer right-4 top-1 text-2xl text-gray-600 toggle-button fa fa-eye"
65 )]
66 pub eye_active: &'static str,
67
68 /// The icon when the password is not visible. Assuming fontawesome icons are used by default.
69 #[props(
70 default = "cursor-pointer right-4 top-1 text-2xl text-gray-600 toggle-button fa fa-eye-slash"
71 )]
72 pub eye_disabled: &'static str,
73
74 // Accessibility and SEO-related attributes:
75 /// The ID attribute of the input element.
76 #[props(default = "")]
77 pub id: &'static str,
78
79 /// The placeholder text to be displayed in the input element.
80 #[props(default = "")]
81 pub placeholder: &'static str,
82
83 /// The aria-label attribute for screen readers, providing a label for accessibility.
84 #[props(default = "")]
85 pub aria_label: &'static str,
86
87 /// The aria-required attribute for screen readers, indicating whether the input is required.
88 #[props(default = "true")]
89 pub aria_required: &'static str,
90
91 /// The aria-invalid attribute for screen readers, indicating whether the input value is invalid.
92 #[props(default = "true")]
93 pub aria_invalid: &'static str,
94
95 /// The aria-describedby attribute for screen readers, describing the input element's error message.
96 #[props(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 #[props(default = "")]
102 pub accept: &'static str,
103
104 /// The alternative text for `<input type="image">`. Required for accessibility.
105 #[props(default = "")]
106 pub alt: &'static str,
107
108 /// Controls automatic capitalization in inputted text.
109 #[props(default = "")]
110 pub autocapitalize: &'static str,
111
112 /// Hint for the browser's autofill feature.
113 #[props(default = "")]
114 pub autocomplete: &'static str,
115
116 /// Media capture input method in file upload controls.
117 #[props(default = "")]
118 pub capture: &'static str,
119
120 /// Whether the control is checked (for checkboxes or radio buttons).
121 #[props(default = false)]
122 pub checked: bool,
123
124 /// Name of the form field to use for sending the element's directionality in form submission.
125 #[props(default = "")]
126 pub dirname: &'static str,
127
128 /// Whether the form control is disabled.
129 #[props(default = false)]
130 pub disabled: bool,
131
132 /// Associates the input with a specific form element.
133 #[props(default = "")]
134 pub form: &'static str,
135
136 /// URL to use for form submission (for `<input type="image" | "submit">`).
137 #[props(default = "")]
138 pub formaction: &'static str,
139
140 /// Form data set encoding type for submission (for `<input type="image" | "submit">`).
141 #[props(default = "")]
142 pub formenctype: &'static str,
143
144 /// HTTP method to use for form submission (for `<input type="image" | "submit">`).
145 #[props(default = "")]
146 pub formmethod: &'static str,
147
148 /// Bypass form validation for submission (for `<input type="image" | "submit">`).
149 #[props(default = false)]
150 pub formnovalidate: bool,
151
152 /// Browsing context for form submission (for `<input type="image" | "submit">`).
153 #[props(default = "")]
154 pub formtarget: &'static str,
155
156 /// Same as the `height` attribute for `<img>` elements.
157 #[props(default = None)]
158 pub height: Option<u32>,
159
160 /// ID of the `<datalist>` element to use for autocomplete suggestions.
161 #[props(default = "")]
162 pub list: &'static str,
163
164 /// The maximum value for date, number, range, etc.
165 #[props(default = "")]
166 pub max: &'static str,
167
168 /// Maximum length of the input value (in characters).
169 #[props(default = None)]
170 pub maxlength: Option<usize>,
171
172 /// The minimum value for date, number, range, etc.
173 #[props(default = "")]
174 pub min: &'static str,
175
176 /// Minimum length of the input value (in characters).
177 #[props(default = None)]
178 pub minlength: Option<usize>,
179
180 /// Boolean indicating whether multiple values are allowed (for file inputs, emails, etc.).
181 #[props(default = false)]
182 pub multiple: bool,
183
184 /// Regex pattern the value must match to be valid.
185 #[props(default = ".*")]
186 pub pattern: &'static str,
187
188 /// Boolean indicating whether the input is read-only.
189 #[props(default = false)]
190 pub readonly: bool,
191
192 /// Size of the input field (e.g., character width).
193 #[props(default = None)]
194 pub size: Option<u32>,
195
196 /// Address of the image resource for `<input type="image">`.
197 #[props(default = "")]
198 pub src: &'static str,
199
200 /// Incremental values that are valid for the input.
201 #[props(default = "")]
202 pub step: &'static str,
203
204 /// The value of the control (used for two-way data binding).
205 #[props(default = "")]
206 pub value: &'static str,
207
208 /// Same as the `width` attribute for `<img>` elements.
209 #[props(default = None)]
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 state hook 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/// - `handle` - A state hook to set the value of the input.
222/// - `validate_function` - A function to validate the input value.
223///
224/// # Returns
225/// (Element): A Dioxus element representation of the input component.
226///
227/// # Examples
228/// ```rust
229/// use regex::Regex;
230/// use input_rs::dioxus::Input;
231/// use dioxus::prelude::*;
232///
233/// #[derive(Debug, Default, Clone)]
234/// struct LoginUserSchema {
235/// email: String,
236/// password: String,
237/// }
238///
239/// fn LoginFormOne() -> Element {
240/// let error_handle = use_signal(|| String::new());
241/// let email_valid_handle = use_signal(|| true);
242/// let password_valid_handle = use_signal(|| true);
243///
244/// let email_handle = use_signal(|| String::new());
245/// let password_handle = use_signal(|| String::new());
246///
247/// let validate_email = |email: String| {
248/// let pattern = Regex::new(r"^[^ ]+@[^ ]+\.[a-z]{2,3}$").unwrap();
249/// pattern.is_match(&email)
250/// };
251///
252/// let validate_password = |password: String| {
253/// !password.is_empty()
254/// };
255///
256/// let onsubmit = {
257/// move |e: FormEvent| {
258/// e.stop_propagation();
259/// // Custom logic for your endpoint goes here.
260/// }
261/// };
262///
263/// rsx! {
264/// div {
265/// class: "form-one-content",
266/// role: "main",
267/// aria_label: "Sign In Form",
268/// div {
269/// class: "text",
270/// h2 { "Sign In" }
271/// if !error_handle().is_empty() {
272/// div { class: "error", "{error_handle}" }
273/// }
274/// }
275/// form {
276/// onsubmit: onsubmit,
277/// aria_label: "Sign In Form",
278/// Input {
279/// r#type: "text",
280/// handle: email_handle,
281/// name: "email",
282/// placeholder: "Email",
283/// icon_class: "fas fa-user",
284/// error_message: "Enter a valid email address",
285/// field_class: "form-one-field",
286/// error_class: "error-txt",
287/// required: true,
288/// valid_handle: email_valid_handle,
289/// validate_function: validate_email,
290/// }
291/// Input {
292/// r#type: "password",
293/// handle: password_handle,
294/// name: "password",
295/// placeholder: "Password",
296/// icon_class: "fas fa-lock",
297/// error_message: "Password can't be blank!",
298/// field_class: "form-one-field",
299/// error_class: "error-txt",
300/// required: true,
301/// valid_handle: password_valid_handle,
302/// validate_function: validate_password,
303/// eye_active: "fa fa-eye",
304/// eye_disabled: "fa fa-eye-slash",
305/// }
306/// div {
307/// class: "form-one-forgot-pass",
308/// a {
309/// href: "#",
310/// aria_label: "Forgot Password?",
311/// "Forgot Password?"
312/// }
313/// }
314/// button {
315/// r#type: "submit",
316/// "Sign in"
317/// }
318/// div {
319/// class: "sign-up",
320/// "Not a member? ",
321/// a {
322/// href: "#",
323/// aria_label: "Sign up now",
324/// "Sign up now"
325/// }
326/// }
327/// }
328/// }
329/// }
330/// }
331/// ```
332#[component]
333pub fn Input(mut props: InputProps) -> Element {
334 let mut is_eye_active = use_signal(|| false);
335 let password_type = if is_eye_active() { "text" } else { "password" };
336 let mut country = use_signal(String::default);
337
338 let onchange = {
339 move |e: Event<FormData>| {
340 let value = e.value();
341 props.handle.set(value.clone());
342 props.valid_handle.set((props.validate_function)(value));
343 }
344 };
345
346 let on_select_change = {
347 move |e: Event<FormData>| {
348 let value = e.value();
349 country.set(value.clone());
350 props.handle.set(value);
351 }
352 };
353
354 let on_phone_number_input = {
355 move |e: Event<FormData>| {
356 let input_value = e.value();
357 for (code, _, _, _, _, _) in &COUNTRY_CODES {
358 if code.starts_with(&input_value) {
359 country.set(input_value);
360 break;
361 }
362 }
363 // Filter out non-numeric characters
364 let numeric_value: String = e.value().chars().filter(|c| c.is_numeric()).collect();
365 props.handle.set('+'.to_string() + &numeric_value);
366 }
367 };
368
369 let toggle_eye_icon = {
370 move |_| {
371 is_eye_active.set(!is_eye_active());
372 }
373 };
374
375 let input_field = match props.r#type {
376 "password" => rsx! {
377 input {
378 r#type: "{password_type}",
379 class: "{props.input_class}",
380 id: "{props.id}",
381 name: "{props.name}",
382 value: "{props.handle}",
383 placeholder: "{props.placeholder}",
384 aria_label: "{props.aria_label}",
385 aria_required: "{props.aria_required}",
386 aria_invalid: "{props.aria_invalid}",
387 aria_describedby: "{props.aria_describedby}",
388 oninput: onchange,
389 required: props.required,
390 autocomplete: props.autocomplete,
391 autocapitalize: props.autocapitalize,
392 readonly: "{props.readonly}",
393 disabled: "{props.disabled}",
394 minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
395 maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
396 pattern: "{props.pattern}",
397 size: props.size.map(|v| v.to_string()).unwrap_or_default(),
398 }
399 span {
400 class: if is_eye_active() { props.eye_active } else { props.eye_disabled },
401 onclick: toggle_eye_icon
402 }
403 },
404 "tel" => rsx! {
405 select {
406 style: "max-width: 55px; font-size: 14px; padding: 10px;",
407 onchange: on_select_change,
408 { COUNTRY_CODES.iter().map(|(code, emoji, _, name, _, _)| rsx! {
409 option { value: "{code}", selected: *code == country(), "{emoji} {name} ({code})" }
410 })}
411 }
412 input {
413 r#type: "tel",
414 class: "{props.input_class}",
415 id: "{props.id}",
416 name: "{props.name}",
417 value: "{props.handle}",
418 placeholder: "{props.placeholder}",
419 aria_label: "{props.aria_label}",
420 aria_required: "{props.aria_required}",
421 aria_invalid: "{props.aria_invalid}",
422 aria_describedby: "{props.aria_describedby}",
423 oninput: on_phone_number_input,
424 required: props.required,
425 autocomplete: props.autocomplete,
426 autocapitalize: props.autocapitalize,
427 readonly: "{props.readonly}",
428 disabled: "{props.disabled}",
429 minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
430 maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
431 pattern: "{props.pattern}",
432 size: props.size.map(|v| v.to_string()).unwrap_or_default(),
433 }
434 },
435 "textarea" => rsx! {
436 textarea {
437 class: "{props.input_class}",
438 id: "{props.id}",
439 name: "{props.name}",
440 value: "{props.handle}",
441 placeholder: "{props.placeholder}",
442 aria_label: "{props.aria_label}",
443 aria_required: "{props.aria_required}",
444 aria_invalid: "{props.aria_invalid}",
445 aria_describedby: "{props.aria_describedby}",
446 oninput: onchange,
447 required: props.required,
448 readonly: "{props.readonly}",
449 disabled: "{props.disabled}",
450 minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
451 maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
452 }
453 },
454 _ => rsx! {
455 input {
456 r#type: "{props.r#type}",
457 class: "{props.input_class}",
458 id: "{props.id}",
459 name: "{props.name}",
460 value: "{props.handle}",
461 placeholder: "{props.placeholder}",
462 aria_label: "{props.aria_label}",
463 aria_required: "{props.aria_required}",
464 aria_invalid: "{props.aria_invalid}",
465 aria_describedby: "{props.aria_describedby}",
466 oninput: onchange,
467 required: props.required,
468 autocomplete: props.autocomplete,
469 autocapitalize: props.autocapitalize,
470 readonly: "{props.readonly}",
471 disabled: "{props.disabled}",
472 minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(),
473 maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(),
474 pattern: "{props.pattern}",
475 size: props.size.map(|v| v.to_string()).unwrap_or_default(),
476 }
477 },
478 };
479
480 rsx! {
481 div {
482 class: "{props.class}",
483 label {
484 class: "{props.label_class}",
485 r#for: "{props.id}",
486 "{props.label}"
487 }
488 div {
489 class: "{props.field_class}",
490 {input_field}
491 span {class: "{props.icon_class}" }
492 }
493 if !(props.valid_handle)() {
494 div {
495 class: "{props.error_class}",
496 id: "{props.aria_describedby}",
497 "{props.error_message}"
498 }
499 }
500 }
501 }
502}