From 7ecb37ad354abba1724e0d2100f46edcf6347a35 Mon Sep 17 00:00:00 2001 From: Mahmoud Harmouch Date: Fri, 6 Dec 2024 13:11:01 +0200 Subject: [PATCH] feat: add dioxus support --- .gitignore | 4 +- Cargo.toml | 6 +- examples/dioxus/Cargo.toml | 10 + examples/dioxus/Dioxus.toml | 48 ++++ examples/dioxus/README.md | 69 +++++ examples/dioxus/src/main.rs | 372 ++++++++++++++++++++++++++ examples/yew/README.md | 86 ++++-- src/dioxus.rs | 502 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + src/yew.rs | 7 +- 10 files changed, 1073 insertions(+), 33 deletions(-) create mode 100755 examples/dioxus/Cargo.toml create mode 100755 examples/dioxus/Dioxus.toml create mode 100644 examples/dioxus/README.md create mode 100755 examples/dioxus/src/main.rs create mode 100644 src/dioxus.rs diff --git a/.gitignore b/.gitignore index 3217bdd..251bc37 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Generated by Cargo # will have compiled files and executables -/target/ +**/target/** # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html @@ -8,4 +8,4 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk -**/dist/* \ No newline at end of file +**/dist/* diff --git a/Cargo.toml b/Cargo.toml index a3b1bc1..62be93a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,15 +15,17 @@ exclude = ["assets", "examples"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -web-sys = { version = "0.3.64", default-features = false } +web-sys = { version = "0.3", default-features = false } yew = { version = "0.21.0", default-features = false, optional = true } +dioxus = { version = "0.5", optional = true } [dev-dependencies] regex = "1.10.2" serde = { version = "1.0.193", features = ["derive"] } [features] -yew = ["dep:yew"] +yew = ["dep:yew", ] +dio = ["dioxus", ] [profile.release] opt-level = "z" diff --git a/examples/dioxus/Cargo.toml b/examples/dioxus/Cargo.toml new file mode 100755 index 0000000..72889bb --- /dev/null +++ b/examples/dioxus/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "input-rs-dioxus-example" +version = "0.1.0" +edition = "2021" + +[dependencies] +dioxus = { version = "0.5.6", features = ["web"] } +input-rs = { path = "../../", features = ["dio"] } +dioxus-logger = "0.5.1" +regex = "1.11.1" diff --git a/examples/dioxus/Dioxus.toml b/examples/dioxus/Dioxus.toml new file mode 100755 index 0000000..ba41305 --- /dev/null +++ b/examples/dioxus/Dioxus.toml @@ -0,0 +1,48 @@ +[application] + +# App (Project) Name +name = "input-rs" + +# Dioxus App Default Platform +# desktop, web +default_platform = "web" + +# `build` & `serve` dist path +out_dir = "dist" + +# resource (assets) file folder +asset_dir = "assets" + +[web.app] + +# HTML title tag content +title = "input-rs" + +[web.watcher] + +# when watcher trigger, regenerate the `index.html` +reload_html = true + +# which files or dirs will be watcher monitoring +watch_path = ["src", "assets"] + +# include `assets` in web platform +[web.resource] + +# CSS style file +style = [ + # online cdn. + "https://unpkg.com/tailwindcss@2.2.19/dist/tailwind.min.css" +] + +# Javascript code file +script = [ + # online cdn. + "https://kit.fontawesome.com/8f223ead6e.js" +] + +[web.resource.dev] + +# Javascript code file +# serve: [dev-server] only +script = [] diff --git a/examples/dioxus/README.md b/examples/dioxus/README.md new file mode 100644 index 0000000..842628e --- /dev/null +++ b/examples/dioxus/README.md @@ -0,0 +1,69 @@ +# 📚 Input RS Dioxus Tailwind Components + +## 🛠️ Pre-requisites: + +### 🐧 **Linux Users** + +1. **Install [`rustup`](https://www.rust-lang.org/tools/install)**: + + ```sh + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + ``` + +1. Install [`Dioxus CLI`](https://dioxuslabs.com/learn/0.5/getting_started): + + ```sh + cargo install dioxus-cli + ``` + +### 🪟 **Windows Users** + +1. **Download and install `rustup`**: Follow the installation instructions [here](https://www.rust-lang.org/tools/install). + +1. **Install [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install)**: Open PowerShell as administrator and run: + + ```sh + wsl --install + ``` + +1. **Reset Network Stack**: In PowerShell (administrator mode), run: + + ```sh + netsh int ip reset all + netsh winsock reset + ``` + +1. **Install Linux packages in WSL**: Once inside your WSL terminal, update and install required dependencies: + + ```sh + sudo apt update + sudo apt install build-essential pkg-config libudev-dev + ``` + +1. Install [`Dioxus CLI`](https://dioxuslabs.com/learn/0.5/getting_started): + + ```sh + cargo install dioxus-cli + ``` + +## 🚀 Building and Running + +1. Fork/Clone the GitHub repository. + + ```sh + git clone https://github.com/opensass/input-rs + ``` + +1. Navigate to the application directory. + + ```sh + cd input-rs/examples/dioxus + ``` + +1. Run the client: + + ```sh + dx serve --port 3000 + ``` + +Navigate to http://localhost:3000 to explore the landing page. diff --git a/examples/dioxus/src/main.rs b/examples/dioxus/src/main.rs new file mode 100755 index 0000000..5be2cfe --- /dev/null +++ b/examples/dioxus/src/main.rs @@ -0,0 +1,372 @@ +use dioxus::prelude::*; +use dioxus_logger::tracing; +use input_rs::dioxus::Input; +use regex::Regex; + +pub fn validate_email(email: String) -> bool { + let pattern = Regex::new(r"^[^ ]+@[^ ]+\.[a-z]{2,3}$").unwrap(); + pattern.is_match(&email) +} + +pub fn validate_input(field: String) -> bool { + !&field.is_empty() +} + +fn main() { + dioxus_logger::init(tracing::Level::INFO).expect("failed to init logger"); + tracing::info!("starting app"); + launch(app); +} + +fn app() -> Element { + rsx! { + MultiStepFormOne {} + } +} + +#[allow(non_snake_case)] +pub fn MultiStepFormOne() -> Element { + let mut error = use_signal(String::new); + let email_valid = use_signal(|| true); + let full_name_valid = use_signal(|| true); + let phone_number_valid = use_signal(|| true); + let address_valid = use_signal(|| true); + let birthday_valid = use_signal(|| true); + let mut gender_valid = use_signal(|| true); + let username_valid = use_signal(|| true); + let password_valid = use_signal(|| true); + + let input_email = use_signal(String::new); + let input_full_name = use_signal(String::new); + let input_phone_number = use_signal(String::new); + let input_address = use_signal(String::new); + let input_birthday = use_signal(String::new); + let mut input_gender = use_signal(String::new); + let input_username = use_signal(String::new); + let input_password = use_signal(String::new); + + let mut current_step = use_signal(|| 0); + + let on_next = { + move |_| match current_step() { + 0 => { + if full_name_valid() && email_valid() { + current_step.set(current_step() + 1); + error.set(String::new()); + } else { + error.set("Please provide a valid full name and email address!".to_string()); + } + } + 1 => { + if phone_number_valid() && address_valid() { + current_step.set(current_step() + 1); + error.set(String::new()); + } else { + error.set("Please provide a valid phone number and address!".to_string()); + } + } + 2 => { + if birthday_valid() && gender_valid() { + current_step.set(current_step() + 1); + error.set(String::new()); + } else { + error.set("Please provide a valid birth date and gender!".to_string()); + } + } + _ => (), + } + }; + + let on_previous = { + move |_| { + if current_step() > 0 { + current_step.set(current_step() - 1); + } + } + }; + + let on_gender_change = { + move |e: Event| { + let value = e.value(); + input_gender.set(value.clone()); + gender_valid.set(validate_input(value)); + } + }; + + let render_progress_item = |index: usize| { + rsx! { + div { + class: "flex items-center text-left justify-center my-10", + for step in 0..4 { + div { + class: "step", + p { + class: "font-semibold mb-2 text-pink-800", + "Step {step + 1}" + } + div { + class: "flex items-center", + div { + class: "outer-check bullet border-2 border-pink-800 rounded-full h-7 w-7 flex items-center justify-center", + span { + class: "step-index font-semibold text-pink-800", + "{step + 1}" + } + if index > step { + span { + class: "check bg-pink-800 z-50" + } + } + } + if step < 3 { + span { + class: if index > step { "line bg-pink-800 h-1 w-10" } else { "line bg-grey-400 h-1 w-10" } + } + } + } + } + } + } + } + }; + + let current_step_content = match current_step() { + 0 => rsx! { + div { + class: "page ml-0 transition-transform duration-300", + div { + class: "title text-left text-xl font-semibold mb-4", + "Personal Information" + } + Input { + r#type: "text", + label: "Full Name", + handle: input_full_name, + placeholder: "Full Name", + error_message: "Full name can't be blank!", + required: true, + valid_handle: full_name_valid, + validate_function: validate_input, + class: "field mb-6", + field_class: "validate-input mb-6", + label_class: "label font-semibold text-pink-800", + input_class: "w-full border border-pink-800 rounded px-4 py-2", + error_class: "text-red-500 text-sm my-2", + } + Input { + r#type: "text", + label: "Email", + handle: input_email, + placeholder: "Email", + error_message: "Enter a valid email address!", + required: true, + valid_handle: email_valid, + validate_function: validate_email, + class: "field mb-6", + field_class: "validate-input mb-6", + label_class: "label font-semibold text-pink-800", + input_class: "w-full border border-pink-800 rounded px-4 py-2", + error_class: "text-red-500 text-sm my-2", + } + button { + class: "next bg-purple-800 hover:bg-purple-700 text-white rounded px-4 mt-10 py-2 w-full font-semibold mb-4", + onclick: on_next, + "Next" + } + } + }, + 1 => rsx! { + div { + class: "page transition-transform duration-300", + div { + class: "title text-left text-xl font-semibold mb-4", + "Contact Details" + } + Input { + r#type: "tel", + label: "Phone Number", + handle: input_phone_number, + placeholder: "+19999", + error_message: "Phone number can't be blank!", + required: true, + valid_handle: phone_number_valid, + validate_function: validate_input, + class: "field mb-6", + field_class: "flex telephone-input validate-input mb-6", + label_class: "label font-semibold text-pink-800", + input_class: "w-full border border-pink-800 rounded px-4 py-2", + error_class: "text-red-500 text-sm my-2", + } + Input { + r#type: "text", + label: "Address", + handle: input_address, + placeholder: "Address", + error_message: "Address can't be blank!", + required: true, + valid_handle: address_valid, + validate_function: validate_input, + class: "field mb-6", + field_class: "validate-input mb-6", + label_class: "label font-semibold text-pink-800", + input_class: "w-full border border-pink-800 rounded px-4 py-2", + error_class: "text-red-500 text-sm my-2", + } + div { + class: "justify-center flex field btns text-center space-x-5 mt-10", + button { + class: "prev bg-pink-800 hover:bg-pink-700 text-white rounded px-4 py-2 font-semibold", + onclick: on_previous, + "Previous" + } + button { + class: "next bg-purple-800 hover:bg-purple-700 text-white rounded px-4 py-2 font-semibold", + onclick: on_next, + "Next" + } + } + } + }, + 2 => rsx! { + div { + class: "page transition-transform duration-300", + div { + class: "title text-left text-xl font-semibold mb-4", + "Date of Birth" + } + Input { + r#type: "date", + label: "Date of Birth", + handle: input_birthday, + placeholder: "Birthday", + error_message: "Birthday can't be blank!", + required: true, + valid_handle: birthday_valid, + validate_function: validate_input, + class: "field mb-6", + field_class: "validate-input mb-6", + label_class: "label font-semibold text-pink-800", + input_class: "w-full border border-pink-800 rounded px-4 py-2", + error_class: "text-red-500 text-sm my-2", + } + div { + class: "field mb-6", + div { + class: "label font-semibold text-pink-800", + "Gender" + } + select { + class: "w-full border border-pink-800 rounded px-4 py-2", + id: "gender", + required: true, + aria_placeholder: "Gender", + oninput: on_gender_change, + option { "Male" } + option { "Female" } + option { "Non-binary" } + option { "Other" } + } + } + if !gender_valid() { + div { + class: "error-txt text-red-500 text-sm my-2", + "Gender can't be blank!" + } + } + div { + class: "justify-center flex field btns text-center space-x-5 mt-10", + button { + class: "prev bg-pink-800 hover:bg-pink-700 text-white rounded px-4 py-2 font-semibold", + onclick: on_previous, + "Previous" + } + button { + class: "next bg-purple-800 hover:bg-purple-700 text-white rounded px-4 py-2 font-semibold", + onclick: on_next, + "Next" + } + } + } + }, + 3 => rsx! { + div { + class: "page transition-transform duration-300", + div { + class: "title text-left text-xl font-semibold mb-4", + "Account Details" + } + Input { + r#type: "text", + label: "Username", + handle: input_username, + placeholder: "Username", + error_message: "Username can't be blank!", + required: true, + valid_handle: username_valid, + validate_function: validate_input, + class: "field mb-6", + field_class: "validate-input mb-6", + label_class: "label font-semibold text-pink-800", + input_class: "w-full border border-pink-800 rounded px-4 py-2", + error_class: "text-red-500 text-sm my-2", + } + Input { + r#type: "password", + label: "Password", + handle: input_password, + placeholder: "Password", + error_message: "Password can't be blank!", + required: true, + valid_handle: password_valid, + validate_function: validate_input, + field_class: "relative mt-2 mb-2", + input_class: "input w-full px-4 py-2 rounded border border-pink-800 bg-gray-100", + error_class: "text-red-500 absolute text-sm", + eye_active: "cursor-pointer absolute right-4 top-1/2 transform -translate-y-1/2 text-2xl text-gray-600 toggle-button fa fa-eye", + eye_disabled: "cursor-pointer absolute right-4 top-1/2 transform -translate-y-1/2 text-2xl text-gray-600 toggle-button fa fa-eye-slash", + } + div { + class: "justify-center flex field btns text-center space-x-5 mt-10", + button { + class: "prev bg-pink-800 hover:bg-pink-700 text-white rounded px-4 py-2 font-semibold", + onclick: on_previous, + "Previous" + } + button { + class: "submit bg-purple-800 hover:bg-purple-700 text-white rounded px-4 py-2 font-semibold", + "Submit" + } + } + } + }, + _ => rsx! {}, + }; + + rsx! { + div { + class: "text-black min-h-screen bg-gradient-to-tr from-indigo-500 to-pink-500 flex items-center justify-center", + style: "border: none; width: 100vw; height: 100vh; overflow: hidden; position: fixed; top: 0; left: 0;", + div { + class: "container mx-auto p-10 bg-white text-center rounded w-2/3 md:w-1/3 lg:w-1/3", + if !error().is_empty() { + div { + class: "error bg-red-600 text-white px-4 py-3 mb-5 font-semibold rounded-md text-center text-base", + "{error()}" + } + } + header { + class: "text-4xl font-semibold mb-3", + "Multi-Step Form" + } + div { + class: "flex items-center text-left justify-center my-10", + { render_progress_item(current_step())} + } + div { + class: "form-outer slide-page text-left", + {current_step_content} + } + } + } + } +} diff --git a/examples/yew/README.md b/examples/yew/README.md index 740ad59..0d6f2fc 100644 --- a/examples/yew/README.md +++ b/examples/yew/README.md @@ -1,44 +1,85 @@ -# 📚 Yew Tailwind Components +# 📚 Input RS Yew Tailwind Components ## 🛠️ Pre-requisites: -1. Install [`rustup`](https://www.rust-lang.org/tools/install): +### 🐧 **Linux Users** - ```bash - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - ``` +1. **Install [`rustup`](https://www.rust-lang.org/tools/install)**: -1. Install [`trunk`](https://trunkrs.dev/): + ```sh + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + ``` - ```bash - cargo install --locked trunk - ``` +1. **Install [`trunk`](https://trunkrs.dev/)**: -1. Add Wasm target: + ```sh + cargo install --locked trunk + ``` - ```bash - rustup target add wasm32-unknown-unknown - ``` +1. **Add the Wasm target**: + + ```sh + rustup target add wasm32-unknown-unknown + ``` + +### 🪟 **Windows Users** + +1. **Download and install `rustup`**: Follow the installation instructions [here](https://www.rust-lang.org/tools/install). + +1. **Install [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install)**: Open PowerShell as administrator and run: + + ```sh + wsl --install + ``` + +1. **Reset Network Stack**: In PowerShell (administrator mode), run: + + ```sh + netsh int ip reset all + netsh winsock reset + ``` + +1. **Install Linux packages in WSL**: Once inside your WSL terminal, update and install required dependencies: + + ```sh + sudo apt update + sudo apt install build-essential pkg-config libudev-dev + ``` + +1. **Install `trunk`**: + + ```sh + cargo install --locked trunk + ``` + +1. **Add the Wasm target**: + + ```sh + rustup target add wasm32-unknown-unknown + ``` ## 🚀 Building and Running 1. Fork/Clone the GitHub repository. - ```bash - git clone https://github.com/opensass/input-rs - ``` + ```bash + git clone https://github.com/opensass/input-rs + ``` 1. Navigate to the application directory. - ```bash - cd input-rs/examples/yew - ``` + ```bash + cd input-rs/examples/yew + ``` 1. Run the client: - ```sh - trunk serve --port 3000 - ``` + ```sh + trunk serve --port 3000 + ``` + +1. Uncomment this line: + https://github.com/opensass/input-rs/blob/f17f89fc85f464381beb5bc9c5596a9def7924ce/examples/yew/index.html#L5 Navigate to http://localhost:3000 to explore all available components. @@ -65,4 +106,3 @@ This section lists components implemented using the [Tailwind CSS](https://tailw | ID | Preview | Demo | Localhost | |---|---|---|---| | 1 | ![Component 1](./assets/multi-step-form-one.png) | [![Netlify Status](https://api.netlify.com/api/v1/badges/68d1469e-05ee-4acd-9368-b67d9e53bc2e/deploy-status)](https://tailwind-multi-step-form-1.netlify.app/) | [Localhost](http://localhost:3000/multi-step/1) | - diff --git a/src/dioxus.rs b/src/dioxus.rs new file mode 100644 index 0000000..c6d60db --- /dev/null +++ b/src/dioxus.rs @@ -0,0 +1,502 @@ +use crate::countries::COUNTRY_CODES; +use dioxus::prelude::*; + +/// Props for a custom input component. +/// This struct includes all possible attributes for an HTML `` element. +/// See [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input) for more details. +#[derive(Props, PartialEq, Clone)] +pub struct InputProps { + /// The type of the input, e.g., "text", "password", etc. + #[props(default = "text")] + pub r#type: &'static str, + + /// The label to be displayed for the input field. + #[props(default = "")] + pub label: &'static str, + + /// The name of the input field, used for form submission and accessibility. + #[props(default = "")] + pub name: &'static str, + + /// Indicates whether the input is required or not. + #[props(default = false)] + pub required: bool, + + /// The error message to display when there is a validation error. + #[props(default = "")] + pub error_message: &'static str, + + /// The CSS class to be applied to all inner elements. + #[props(default = "")] + pub input_class: &'static str, + + /// The CSS class to be applied to the inner input element and icon. + #[props(default = "")] + pub field_class: &'static str, + + /// The CSS class to be applied to the label for the input element. + #[props(default = "")] + pub label_class: &'static str, + + /// The CSS class to be applied to the input element. + #[props(default = "")] + pub class: &'static str, + + /// The CSS class to be applied to the error div element. + #[props(default = "")] + pub error_class: &'static str, + + /// The CSS class to be applied to the icon element. + #[props(default = "")] + pub icon_class: &'static str, + + /// The state handle for managing the value of the input. + pub handle: Signal, + + /// The state handle for managing the validity state of the input. + pub valid_handle: Signal, + + /// A callback function to validate the input value. It takes a `String` as input and returns a `bool`. + pub validate_function: fn(String) -> bool, + + /// The icon when the password is visible. Assuming fontawesome icons are used by default. + #[props( + default = "cursor-pointer right-4 top-1 text-2xl text-gray-600 toggle-button fa fa-eye" + )] + pub eye_active: &'static str, + + /// The icon when the password is not visible. Assuming fontawesome icons are used by default. + #[props( + default = "cursor-pointer right-4 top-1 text-2xl text-gray-600 toggle-button fa fa-eye-slash" + )] + pub eye_disabled: &'static str, + + // Accessibility and SEO-related attributes: + /// The ID attribute of the input element. + #[props(default = "")] + pub id: &'static str, + + /// The placeholder text to be displayed in the input element. + #[props(default = "")] + pub placeholder: &'static str, + + /// The aria-label attribute for screen readers, providing a label for accessibility. + #[props(default = "")] + pub aria_label: &'static str, + + /// The aria-required attribute for screen readers, indicating whether the input is required. + #[props(default = "true")] + pub aria_required: &'static str, + + /// The aria-invalid attribute for screen readers, indicating whether the input value is invalid. + #[props(default = "true")] + pub aria_invalid: &'static str, + + /// The aria-describedby attribute for screen readers, describing the input element's error message. + #[props(default = "")] + pub aria_describedby: &'static str, + + // Newly added attributes from MDN: + /// Hint for expected file type in file upload controls. + #[props(default = "")] + pub accept: &'static str, + + /// The alternative text for ``. Required for accessibility. + #[props(default = "")] + pub alt: &'static str, + + /// Controls automatic capitalization in inputted text. + #[props(default = "")] + pub autocapitalize: &'static str, + + /// Hint for the browser's autofill feature. + #[props(default = "")] + pub autocomplete: &'static str, + + /// Media capture input method in file upload controls. + #[props(default = "")] + pub capture: &'static str, + + /// Whether the control is checked (for checkboxes or radio buttons). + #[props(default = false)] + pub checked: bool, + + /// Name of the form field to use for sending the element's directionality in form submission. + #[props(default = "")] + pub dirname: &'static str, + + /// Whether the form control is disabled. + #[props(default = false)] + pub disabled: bool, + + /// Associates the input with a specific form element. + #[props(default = "")] + pub form: &'static str, + + /// URL to use for form submission (for ``). + #[props(default = "")] + pub formaction: &'static str, + + /// Form data set encoding type for submission (for ``). + #[props(default = "")] + pub formenctype: &'static str, + + /// HTTP method to use for form submission (for ``). + #[props(default = "")] + pub formmethod: &'static str, + + /// Bypass form validation for submission (for ``). + #[props(default = false)] + pub formnovalidate: bool, + + /// Browsing context for form submission (for ``). + #[props(default = "")] + pub formtarget: &'static str, + + /// Same as the `height` attribute for `` elements. + #[props(default = None)] + pub height: Option, + + /// ID of the `` element to use for autocomplete suggestions. + #[props(default = "")] + pub list: &'static str, + + /// The maximum value for date, number, range, etc. + #[props(default = "")] + pub max: &'static str, + + /// Maximum length of the input value (in characters). + #[props(default = None)] + pub maxlength: Option, + + /// The minimum value for date, number, range, etc. + #[props(default = "")] + pub min: &'static str, + + /// Minimum length of the input value (in characters). + #[props(default = None)] + pub minlength: Option, + + /// Boolean indicating whether multiple values are allowed (for file inputs, emails, etc.). + #[props(default = false)] + pub multiple: bool, + + /// Regex pattern the value must match to be valid. + #[props(default = "")] + pub pattern: &'static str, + + /// Boolean indicating whether the input is read-only. + #[props(default = false)] + pub readonly: bool, + + /// Size of the input field (e.g., character width). + #[props(default = None)] + pub size: Option, + + /// Address of the image resource for ``. + #[props(default = "")] + pub src: &'static str, + + /// Incremental values that are valid for the input. + #[props(default = "")] + pub step: &'static str, + + /// The value of the control (used for two-way data binding). + #[props(default = "")] + pub value: &'static str, + + /// Same as the `width` attribute for `` elements. + #[props(default = None)] + pub width: Option, +} + +/// A custom input component that handles user input and validation. +/// +/// # Arguments +/// * `props` - The properties of the component. +/// - `valid_handle` - A state hook to track the validity of the input. +/// - `aria_invalid` - A string representing the 'aria-invalid' attribute value for accessibility. Defaults to "true". +/// - `aria_required` - A string representing the 'aria-required' attribute value for accessibility. Defaults to "true". +/// - `r#type` - The type of the input element. Defaults to "text". +/// - `handle` - A state hook to set the value of the input. +/// - `validate_function` - A function to validate the input value. +/// +/// # Returns +/// (Element): A Dioxus element representation of the input component. +/// +/// # Examples +/// ```rust +/// use regex::Regex; +/// use input_rs::dioxus::Input; +/// use dioxus::prelude::*; +/// +/// #[derive(Debug, Default, Clone)] +/// struct LoginUserSchema { +/// email: String, +/// password: String, +/// } +/// +/// fn LoginFormOne() -> Element { +/// let error_handle = use_signal(|| String::new()); +/// let email_valid_handle = use_signal(|| true); +/// let password_valid_handle = use_signal(|| true); +/// +/// let email_handle = use_signal(|| String::new()); +/// let password_handle = use_signal(|| String::new()); +/// +/// let validate_email = |email: String| { +/// let pattern = Regex::new(r"^[^ ]+@[^ ]+\.[a-z]{2,3}$").unwrap(); +/// pattern.is_match(&email) +/// }; +/// +/// let validate_password = |password: String| { +/// !password.is_empty() +/// }; +/// +/// let onsubmit = { +/// move |e: FormEvent| { +/// e.stop_propagation(); +/// // Custom logic for your endpoint goes here. +/// } +/// }; +/// +/// rsx! { +/// div { +/// class: "form-one-content", +/// role: "main", +/// aria_label: "Sign In Form", +/// div { +/// class: "text", +/// h2 { "Sign In" } +/// if !error_handle().is_empty() { +/// div { class: "error", "{error_handle}" } +/// } +/// } +/// form { +/// onsubmit: onsubmit, +/// aria_label: "Sign In Form", +/// Input { +/// r#type: "text", +/// handle: email_handle, +/// name: "email", +/// placeholder: "Email", +/// icon_class: "fas fa-user", +/// error_message: "Enter a valid email address", +/// field_class: "form-one-field", +/// error_class: "error-txt", +/// required: true, +/// valid_handle: email_valid_handle, +/// validate_function: validate_email, +/// } +/// Input { +/// r#type: "password", +/// handle: password_handle, +/// name: "password", +/// placeholder: "Password", +/// icon_class: "fas fa-lock", +/// error_message: "Password can't be blank!", +/// field_class: "form-one-field", +/// error_class: "error-txt", +/// required: true, +/// valid_handle: password_valid_handle, +/// validate_function: validate_password, +/// eye_active: "fa fa-eye", +/// eye_disabled: "fa fa-eye-slash", +/// } +/// div { +/// class: "form-one-forgot-pass", +/// a { +/// href: "#", +/// aria_label: "Forgot Password?", +/// "Forgot Password?" +/// } +/// } +/// button { +/// r#type: "submit", +/// "Sign in" +/// } +/// div { +/// class: "sign-up", +/// "Not a member? ", +/// a { +/// href: "#", +/// aria_label: "Sign up now", +/// "Sign up now" +/// } +/// } +/// } +/// } +/// } +/// } +/// ``` +#[component] +pub fn Input(mut props: InputProps) -> Element { + let mut is_eye_active = use_signal(|| false); + let password_type = if is_eye_active() { "text" } else { "password" }; + let mut country = use_signal(String::default); + + let onchange = { + move |e: Event| { + let value = e.value(); + props.handle.set(value.clone()); + props.valid_handle.set((props.validate_function)(value)); + } + }; + + let on_select_change = { + move |e: Event| { + let value = e.value(); + country.set(value.clone()); + props.handle.set(value); + } + }; + + let on_phone_number_input = { + move |e: Event| { + let input_value = e.value(); + for (code, _, _, _, _, _) in &COUNTRY_CODES { + if code.starts_with(&input_value) { + country.set(input_value); + break; + } + } + // Filter out non-numeric characters + let numeric_value: String = e.value().chars().filter(|c| c.is_numeric()).collect(); + props.handle.set('+'.to_string() + &numeric_value); + } + }; + + let toggle_eye_icon = { + move |_| { + is_eye_active.set(!is_eye_active()); + } + }; + + let input_field = match props.r#type { + "password" => rsx! { + input { + r#type: "{password_type}", + class: "{props.input_class}", + id: "{props.id}", + name: "{props.name}", + value: "{props.handle}", + placeholder: "{props.placeholder}", + aria_label: "{props.aria_label}", + aria_required: "{props.aria_required}", + aria_invalid: "{props.aria_invalid}", + aria_describedby: "{props.aria_describedby}", + oninput: onchange, + required: props.required, + autocomplete: props.autocomplete, + autocapitalize: props.autocapitalize, + readonly: "{props.readonly}", + disabled: "{props.disabled}", + minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(), + maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(), + pattern: "{props.pattern}", + size: props.size.map(|v| v.to_string()).unwrap_or_default(), + } + span { + class: if is_eye_active() { props.eye_active } else { props.eye_disabled }, + onclick: toggle_eye_icon + } + }, + "tel" => rsx! { + select { + style: "max-width: 55px; font-size: 14px; padding: 10px;", + onchange: on_select_change, + { COUNTRY_CODES.iter().map(|(code, emoji, _, name, _, _)| rsx! { + option { value: "{code}", selected: *code == country(), "{emoji} {name} ({code})" } + })} + } + input { + r#type: "tel", + class: "{props.input_class}", + id: "{props.id}", + name: "{props.name}", + value: "{props.handle}", + placeholder: "{props.placeholder}", + aria_label: "{props.aria_label}", + aria_required: "{props.aria_required}", + aria_invalid: "{props.aria_invalid}", + aria_describedby: "{props.aria_describedby}", + oninput: on_phone_number_input, + required: props.required, + autocomplete: props.autocomplete, + autocapitalize: props.autocapitalize, + readonly: "{props.readonly}", + disabled: "{props.disabled}", + minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(), + maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(), + pattern: "{props.pattern}", + size: props.size.map(|v| v.to_string()).unwrap_or_default(), + } + }, + "textarea" => rsx! { + textarea { + class: "{props.input_class}", + id: "{props.id}", + name: "{props.name}", + value: "{props.handle}", + placeholder: "{props.placeholder}", + aria_label: "{props.aria_label}", + aria_required: "{props.aria_required}", + aria_invalid: "{props.aria_invalid}", + aria_describedby: "{props.aria_describedby}", + oninput: onchange, + required: props.required, + readonly: "{props.readonly}", + disabled: "{props.disabled}", + minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(), + maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(), + } + }, + _ => rsx! { + input { + r#type: "{props.r#type}", + class: "{props.input_class}", + id: "{props.id}", + name: "{props.name}", + value: "{props.handle}", + placeholder: "{props.placeholder}", + aria_label: "{props.aria_label}", + aria_required: "{props.aria_required}", + aria_invalid: "{props.aria_invalid}", + aria_describedby: "{props.aria_describedby}", + oninput: onchange, + required: props.required, + autocomplete: props.autocomplete, + autocapitalize: props.autocapitalize, + readonly: "{props.readonly}", + disabled: "{props.disabled}", + minlength: props.minlength.map(|v| v.to_string()).unwrap_or_default(), + maxlength: props.maxlength.map(|v| v.to_string()).unwrap_or_default(), + pattern: "{props.pattern}", + size: props.size.map(|v| v.to_string()).unwrap_or_default(), + } + }, + }; + + rsx! { + div { + class: "{props.class}", + label { + class: "{props.label_class}", + r#for: "{props.id}", + "{props.label}" + } + div { + class: "{props.field_class}", + {input_field} + span {class: "{props.icon_class}" } + } + if !(props.valid_handle)() { + div { + class: "{props.error_class}", + id: "{props.aria_describedby}", + "{props.error_message}" + } + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 2a25e2d..0290d04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,5 +6,7 @@ #![doc = include_str!("../README.md")] pub(crate) mod countries; +#[cfg(feature = "dio")] +pub mod dioxus; #[cfg(feature = "yew")] pub mod yew; diff --git a/src/yew.rs b/src/yew.rs index 0382469..623eddc 100644 --- a/src/yew.rs +++ b/src/yew.rs @@ -1,5 +1,4 @@ use crate::countries::COUNTRY_CODES; -use std::collections::HashMap; use web_sys::HtmlInputElement; use yew::prelude::*; @@ -343,14 +342,10 @@ pub fn input(props: &Props) -> Html { let valid = *props.valid_handle; - let aria_invalid = props.aria_invalid; - let eye_icon_active = props.eye_active; let eye_icon_disabled = props.eye_disabled; - let aria_required = props.aria_required; - let r#type = props.r#type; let onchange = { @@ -487,7 +482,7 @@ pub fn input(props: &Props) -> Html { size="20" minlength="9" value={(*props.handle).clone()} - maxlength="14" + maxlength={props.maxlength.map(|v| v.to_string())} class={props.input_class} placeholder={props.placeholder} aria-label={props.aria_label}