Skip to main content

dioxus_ui_system/atoms/
password_input.rs

1//! Password Input atom component
2//!
3//! A password input field with show/hide toggle and optional strength indicator.
4
5use crate::styles::Style;
6use crate::theme::{use_style, use_theme};
7use dioxus::prelude::*;
8
9/// Password strength levels
10#[derive(Clone, PartialEq, Debug)]
11pub enum PasswordStrength {
12    Weak,
13    Medium,
14    Strong,
15}
16
17impl PasswordStrength {
18    /// Get the label text for the strength level
19    pub fn label(&self) -> &'static str {
20        match self {
21            PasswordStrength::Weak => "Weak",
22            PasswordStrength::Medium => "Medium",
23            PasswordStrength::Strong => "Strong",
24        }
25    }
26
27    /// Get the color for the strength indicator
28    pub fn color(&self) -> &'static str {
29        match self {
30            PasswordStrength::Weak => "#ef4444",   // red-500
31            PasswordStrength::Medium => "#f59e0b", // amber-500
32            PasswordStrength::Strong => "#22c55e", // green-500
33        }
34    }
35}
36
37/// Calculate password strength based on common criteria
38fn calculate_strength(password: &str) -> PasswordStrength {
39    let length = password.len();
40
41    if length < 6 {
42        return PasswordStrength::Weak;
43    }
44
45    let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase());
46    let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase());
47    let has_digit = password.chars().any(|c| c.is_ascii_digit());
48    let has_special = password.chars().any(|c| !c.is_alphanumeric());
49
50    let criteria_count = [has_lowercase, has_uppercase, has_digit, has_special]
51        .iter()
52        .filter(|&&x| x)
53        .count();
54
55    match criteria_count {
56        0..=1 => PasswordStrength::Weak,
57        2 => PasswordStrength::Medium,
58        _ => PasswordStrength::Strong,
59    }
60}
61
62/// Password Input properties
63#[derive(Props, Clone, PartialEq)]
64pub struct PasswordInputProps {
65    /// Current value
66    #[props(default)]
67    pub value: String,
68    /// Callback when value changes
69    pub on_change: EventHandler<String>,
70    /// Placeholder text
71    #[props(default)]
72    pub placeholder: Option<String>,
73    /// Disabled state
74    #[props(default)]
75    pub disabled: bool,
76    /// Error message to display
77    #[props(default)]
78    pub error: Option<String>,
79    /// Label text
80    #[props(default)]
81    pub label: Option<String>,
82    /// Required field indicator
83    #[props(default)]
84    pub required: bool,
85    /// Show password strength indicator
86    #[props(default)]
87    pub strength_indicator: bool,
88    /// Custom inline styles
89    #[props(default)]
90    pub style: Option<String>,
91    /// Custom class name
92    #[props(default)]
93    pub class: Option<String>,
94    /// Input name attribute
95    #[props(default)]
96    pub name: Option<String>,
97    /// Input id attribute
98    #[props(default)]
99    pub id: Option<String>,
100    /// Autofocus on mount
101    #[props(default)]
102    pub autofocus: bool,
103}
104
105/// Password Input atom component
106///
107/// # Example
108/// ```rust,ignore
109/// use dioxus_ui_system::atoms::PasswordInput;
110///
111/// let mut value = use_signal(|| String::new());
112///
113/// rsx! {
114///     PasswordInput {
115///         value: value(),
116///         placeholder: "Enter password...",
117///         on_change: move |v| value.set(v),
118///         strength_indicator: true,
119///     }
120/// }
121/// ```
122#[component]
123pub fn PasswordInput(props: PasswordInputProps) -> Element {
124    let _theme = use_theme();
125    let mut is_visible = use_signal(|| false);
126    let mut is_focused = use_signal(|| false);
127    let mut is_hovered = use_signal(|| false);
128
129    let disabled = props.disabled;
130    let value = props.value.clone();
131    let has_error = props.error.is_some();
132    let error_clone = props.error.clone();
133    let strength = calculate_strength(&value);
134
135    // Container style (full width flex column)
136    let container_style = use_style(move |t| {
137        Style::new()
138            .flex()
139            .flex_col()
140            .w_full()
141            .gap(&t.spacing, "xs")
142            .build()
143    });
144
145    // Input wrapper style (for positioning the toggle button)
146    let wrapper_style = use_style(move |_t| Style::new().relative().w_full().build());
147
148    // Input style
149    let input_style = use_style(move |t| {
150        let base = Style::new()
151            .w_full()
152            .h_px(40)
153            .rounded(&t.radius, "md")
154            .border(
155                1,
156                if is_focused() {
157                    &t.colors.ring
158                } else {
159                    &t.colors.border
160                },
161            )
162            .bg(&t.colors.background)
163            .text_color(&t.colors.foreground)
164            .px(&t.spacing, "md")
165            .pr_px(40) // Extra padding for toggle button
166            .text(&t.typography, "sm")
167            .transition("all 150ms cubic-bezier(0.4, 0, 0.2, 1)")
168            .outline("none");
169
170        // Disabled state
171        let base = if disabled {
172            base.cursor("not-allowed").opacity(0.5).bg(&t.colors.muted)
173        } else {
174            base.cursor("text")
175        };
176
177        // Focus ring effect
178        let base = if is_focused() && !disabled {
179            Style {
180                box_shadow: Some(format!("0 0 0 1px {}", t.colors.ring.to_rgba())),
181                ..base
182            }
183        } else {
184            base
185        };
186
187        // Hover effect (only when not focused)
188        let base = if is_hovered() && !is_focused() && !disabled {
189            base.border_color(&t.colors.foreground.darken(0.2))
190        } else {
191            base
192        };
193
194        // Error state
195        let base = if has_error {
196            base.border_color(&t.colors.destructive)
197        } else {
198            base
199        };
200
201        base.build()
202    });
203
204    // Toggle button style
205    let toggle_style = use_style(move |t| {
206        Style::new()
207            .absolute()
208            .right("8px")
209            .top("50%")
210            .transform("translateY(-50%)")
211            .w_px(32)
212            .h_px(32)
213            .flex()
214            .items_center()
215            .justify_center()
216            .rounded(&t.radius, "sm")
217            .border(0, &t.colors.border)
218            .bg_transparent()
219            .text_color(&t.colors.muted_foreground)
220            .cursor(if disabled { "not-allowed" } else { "pointer" })
221            .opacity(if disabled { 0.5 } else { 1.0 })
222            .transition("all 150ms ease")
223            .build()
224    });
225
226    // Label style
227    let label_style = use_style(move |t| {
228        Style::new()
229            .text(&t.typography, "sm")
230            .text_color(&t.colors.foreground)
231            .font_weight(500)
232            .build()
233    });
234
235    // Error message style
236    let error_style = use_style(move |t| {
237        Style::new()
238            .text(&t.typography, "xs")
239            .text_color(&t.colors.destructive)
240            .mt(&t.spacing, "xs")
241            .build()
242    });
243
244    // Strength indicator container style
245    let strength_container_style = use_style(move |t| {
246        Style::new()
247            .flex()
248            .flex_col()
249            .gap(&t.spacing, "xs")
250            .mt(&t.spacing, "sm")
251            .build()
252    });
253
254    // Strength bar background style
255    let strength_bar_bg_style = use_style(move |t| {
256        Style::new()
257            .w_full()
258            .h_px(4)
259            .rounded(&t.radius, "full")
260            .bg(&t.colors.muted)
261            .overflow_hidden()
262            .build()
263    });
264
265    // Strength bar fill width and color based on strength
266    let (strength_width, strength_color) = match strength {
267        PasswordStrength::Weak => ("33%", PasswordStrength::Weak.color()),
268        PasswordStrength::Medium => ("66%", PasswordStrength::Medium.color()),
269        PasswordStrength::Strong => ("100%", PasswordStrength::Strong.color()),
270    };
271
272    // Strength bar fill style
273    let strength_bar_fill_style =
274        use_style(move |_t| Style::new().h_full().transition("all 300ms ease").build());
275
276    // Strength label style
277    let strength_label_style = use_style(move |t| {
278        Style::new()
279            .text(&t.typography, "xs")
280            .text_color(&t.colors.muted_foreground)
281            .build()
282    });
283
284    // Combine with custom styles
285    let final_input_style = if let Some(custom) = &props.style {
286        format!("{} {}", input_style(), custom)
287    } else {
288        input_style()
289    };
290
291    let class = props.class.clone().unwrap_or_default();
292    let placeholder = props.placeholder.clone();
293    let name = props.name.clone();
294    let id = props.id.clone();
295    let autofocus = props.autofocus;
296    let input_type = if is_visible() { "text" } else { "password" };
297
298    // Eye icon SVG (show password)
299    let eye_icon = rsx! {
300        svg {
301            view_box: "0 0 24 24",
302            fill: "none",
303            stroke: "currentColor",
304            stroke_width: "2",
305            stroke_linecap: "round",
306            stroke_linejoin: "round",
307            width: "18",
308            height: "18",
309            path { d: "M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" }
310            circle { cx: "12", cy: "12", r: "3" }
311        }
312    };
313
314    // EyeOff icon SVG (hide password)
315    let eye_off_icon = rsx! {
316        svg {
317            view_box: "0 0 24 24",
318            fill: "none",
319            stroke: "currentColor",
320            stroke_width: "2",
321            stroke_linecap: "round",
322            stroke_linejoin: "round",
323            width: "18",
324            height: "18",
325            path { d: "M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" }
326            line { x1: "1", y1: "1", x2: "23", y2: "23" }
327        }
328    };
329
330    rsx! {
331        div {
332            style: "{container_style}",
333
334            // Label
335            if let Some(label_text) = &props.label {
336                label {
337                    style: "{label_style}",
338                    r#for: id.clone(),
339                    "{label_text}"
340                    if props.required {
341                        span { style: "margin-left: 4px; color: #ef4444;", "*" }
342                    }
343                }
344            }
345
346            // Input wrapper with toggle button
347            div {
348                style: "{wrapper_style}",
349
350                input {
351                    r#type: "{input_type}",
352                    style: "{final_input_style}",
353                    class: "{class}",
354                    value: "{value}",
355                    placeholder: placeholder,
356                    name: name,
357                    id: id,
358                    disabled: disabled,
359                    autofocus: autofocus,
360                    required: props.required,
361                    onmouseenter: move |_| is_hovered.set(true),
362                    onmouseleave: move |_| is_hovered.set(false),
363                    onfocus: move |_| is_focused.set(true),
364                    onblur: move |_| is_focused.set(false),
365                    oninput: move |e| {
366                        props.on_change.call(e.value());
367                    },
368                }
369
370                // Toggle visibility button
371                button {
372                    r#type: "button",
373                    style: "{toggle_style}",
374                    disabled: disabled,
375                    aria_label: if is_visible() { "Hide password" } else { "Show password" },
376                    onclick: move |_| {
377                        if !disabled {
378                            is_visible.toggle();
379                        }
380                    },
381                    if is_visible() {
382                        {eye_off_icon}
383                    } else {
384                        {eye_icon}
385                    }
386                }
387            }
388
389            // Error message
390            if let Some(error_msg) = error_clone {
391                span {
392                    style: "{error_style}",
393                    "{error_msg}"
394                }
395            }
396
397            // Strength indicator
398            if props.strength_indicator && !value.is_empty() {
399                div {
400                    style: "{strength_container_style}",
401
402                    // Strength bar
403                    div {
404                        style: "{strength_bar_bg_style}",
405                        div {
406                            style: "{strength_bar_fill_style} width: {strength_width}; background-color: {strength_color};",
407                        }
408                    }
409
410                    // Strength label
411                    span {
412                        style: "{strength_label_style}",
413                        "Password strength: {strength.label()}"
414                    }
415                }
416            }
417        }
418    }
419}