1use crate::styles::Style;
6use crate::theme::{use_style, use_theme};
7use dioxus::prelude::*;
8
9#[derive(Clone, PartialEq, Debug)]
11pub enum PasswordStrength {
12 Weak,
13 Medium,
14 Strong,
15}
16
17impl PasswordStrength {
18 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 pub fn color(&self) -> &'static str {
29 match self {
30 PasswordStrength::Weak => "#ef4444", PasswordStrength::Medium => "#f59e0b", PasswordStrength::Strong => "#22c55e", }
34 }
35}
36
37fn 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#[derive(Props, Clone, PartialEq)]
64pub struct PasswordInputProps {
65 #[props(default)]
67 pub value: String,
68 pub on_change: EventHandler<String>,
70 #[props(default)]
72 pub placeholder: Option<String>,
73 #[props(default)]
75 pub disabled: bool,
76 #[props(default)]
78 pub error: Option<String>,
79 #[props(default)]
81 pub label: Option<String>,
82 #[props(default)]
84 pub required: bool,
85 #[props(default)]
87 pub strength_indicator: bool,
88 #[props(default)]
90 pub style: Option<String>,
91 #[props(default)]
93 pub class: Option<String>,
94 #[props(default)]
96 pub name: Option<String>,
97 #[props(default)]
99 pub id: Option<String>,
100 #[props(default)]
102 pub autofocus: bool,
103}
104
105#[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 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 let wrapper_style = use_style(move |_t| Style::new().relative().w_full().build());
147
148 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) .text(&t.typography, "sm")
167 .transition("all 150ms cubic-bezier(0.4, 0, 0.2, 1)")
168 .outline("none");
169
170 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 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 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 let base = if has_error {
196 base.border_color(&t.colors.destructive)
197 } else {
198 base
199 };
200
201 base.build()
202 });
203
204 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 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 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 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 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 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 let strength_bar_fill_style =
274 use_style(move |_t| Style::new().h_full().transition("all 300ms ease").build());
275
276 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 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 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 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 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 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 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 if let Some(error_msg) = error_clone {
391 span {
392 style: "{error_style}",
393 "{error_msg}"
394 }
395 }
396
397 if props.strength_indicator && !value.is_empty() {
399 div {
400 style: "{strength_container_style}",
401
402 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 span {
412 style: "{strength_label_style}",
413 "Password strength: {strength.label()}"
414 }
415 }
416 }
417 }
418 }
419}