radix_leptos_primitives/components/
otp_field.rs1use leptos::callback::Callback;
2use leptos::children::Children;
3use leptos::prelude::*;
4use wasm_bindgen::JsCast;
5
6#[component]
8pub fn OtpField(
9 #[prop(optional)]
11 value: Option<String>,
12 #[prop(optional)]
14 length: Option<usize>,
15 #[prop(optional)]
17 disabled: Option<bool>,
18 #[prop(optional)]
20 required: Option<bool>,
21 #[prop(optional)]
23 auto_focus: Option<bool>,
24 #[prop(optional)]
26 auto_submit: Option<bool>,
27 #[prop(optional)]
29 input_type: Option<OtpInputType>,
30 #[prop(optional)]
32 on_change: Option<Callback<String>>,
33 #[prop(optional)]
35 on_complete: Option<Callback<String>>,
36 #[prop(optional)]
38 on_submit: Option<Callback<String>>,
39 #[prop(optional)]
41 on_focus: Option<Callback<usize>>,
42 #[prop(optional)]
44 on_blur: Option<Callback<usize>>,
45 #[prop(optional)]
47 class: Option<String>,
48 #[prop(optional)]
50 style: Option<String>,
51 #[prop(optional)]
53 children: Option<Children>,
54) -> impl IntoView {
55 let value = value.unwrap_or_default();
56 let length = length.unwrap_or(6);
57 let disabled = disabled.unwrap_or(false);
58 let required = required.unwrap_or(false);
59 let auto_focus = auto_focus.unwrap_or(true);
60 let auto_submit = auto_submit.unwrap_or(true);
61 let input_type = input_type.unwrap_or_default();
62
63 let class = format!("otp-field {}", class.unwrap_or_default());
64
65 let style = style.unwrap_or_default();
66
67 let chars: Vec<char> = value.chars().take(length).collect();
69 let mut inputs = Vec::new();
70
71 for i in 0..length {
72 let char_value = chars.get(i).copied().unwrap_or(' ');
73 let input_type_str = match input_type {
74 OtpInputType::Numeric => "tel",
75 OtpInputType::Alphanumeric => "text",
76 OtpInputType::Alphabetic => "text",
77 };
78
79 let handle_input = move |event: web_sys::Event| {
80 if let Some(input) = event
81 .target()
82 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
83 {
84 let input_value = input.value();
85 if let Some(callback) = on_change {
86 callback.run(input_value);
87 }
88 }
89 };
90
91 let handle_focus = move |_| {
92 if let Some(callback) = on_focus {
93 callback.run(i);
94 }
95 };
96
97 let handle_blur = move |_| {
98 if let Some(callback) = on_blur {
99 callback.run(i);
100 }
101 };
102
103 inputs.push(view! {
104 <input
105 class="otp-input"
106 type=input_type_str
107 value=char_value.to_string()
108 disabled=disabled
109 required=required
110 maxlength=1
111 autocomplete="one-time-code"
112 on:input=handle_input
113 on:focus=handle_focus
114 on:blur=handle_blur
115 />
116 });
117 }
118
119 view! {
120 <div class=class style=style>
121 <div class="otp-inputs">
122 {inputs}
123 </div>
124 {children.map(|c| c())}
125 </div>
126 }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Default)]
131pub enum OtpInputType {
132 #[default]
133 Numeric,
134 Alphanumeric,
135 Alphabetic,
136}
137
138#[derive(Debug, Clone, PartialEq, Default)]
140pub struct OtpValidation {
141 pub is_valid: bool,
142 pub is_complete: bool,
143 pub length: usize,
144 pub errors: Vec<String>,
145}
146
147#[component]
149pub fn OtpFieldWithValidation(
150 #[prop(optional)]
152 value: Option<String>,
153 #[prop(optional)]
155 length: Option<usize>,
156 #[prop(optional)]
158 disabled: Option<bool>,
159 #[prop(optional)]
161 required: Option<bool>,
162 #[prop(optional)]
164 input_type: Option<OtpInputType>,
165 #[prop(optional)]
167 show_errors: Option<bool>,
168 #[prop(optional)]
170 on_change: Option<Callback<String>>,
171 #[prop(optional)]
173 on_complete: Option<Callback<String>>,
174 #[prop(optional)]
176 on_validation: Option<Callback<OtpValidation>>,
177 #[prop(optional)]
179 class: Option<String>,
180 #[prop(optional)]
182 style: Option<String>,
183) -> impl IntoView {
184 let value = value.unwrap_or_default();
185 let length = length.unwrap_or(6);
186 let disabled = disabled.unwrap_or(false);
187 let required = required.unwrap_or(false);
188 let input_type = input_type.unwrap_or_default();
189 let show_errors = show_errors.unwrap_or(true);
190
191 let validation = validate_otp(&value, length, &input_type);
192 let class = format!(
193 "otp-field-with-validation {} {}",
194 if validation.is_valid {
195 "valid"
196 } else {
197 "invalid"
198 },
199 if validation.is_complete {
200 "complete"
201 } else {
202 "incomplete"
203 }
204 );
205
206 let style = style.unwrap_or_default();
207
208 view! {
209 <div class=class style=style>
210 <OtpField
211 value=value.clone()
212 length=length
213 disabled=disabled
214 required=required
215 input_type=input_type
216 on_change=on_change.unwrap_or_else(|| Callback::new(|_| {}))
217 on_complete=on_complete.unwrap_or_else(|| Callback::new(|_| {}))
218 >
219 <></>
220 </OtpField>
221 {if show_errors && !validation.errors.is_empty() {
222 view! {
223 <div class="otp-errors">
224 {validation.errors.into_iter().map(|error| {
225 view! { <div class="error">{error}</div> }
226 }).collect::<Vec<_>>()}
227 </div>
228 }.into_any()
229 } else {
230 view! { <div></div> }.into_any()
231 }}
232 </div>
233 }
234}
235
236#[component]
238pub fn OtpTimer(
239 #[prop(optional)]
241 duration: Option<usize>,
242 #[prop(optional)]
244 running: Option<bool>,
245 #[prop(optional)]
247 on_expire: Option<Callback<()>>,
248 #[prop(optional)]
250 on_reset: Option<Callback<()>>,
251 #[prop(optional)]
253 class: Option<String>,
254 #[prop(optional)]
256 style: Option<String>,
257) -> impl IntoView {
258 let duration = duration.unwrap_or(300); let running = running.unwrap_or(false);
260 let class = format!(
261 "otp-timer {} {}",
262 if running { "running" } else { "stopped" },
263 class.as_deref().unwrap_or("")
264 );
265
266 view! {
267 <div class=class style=style>
268 <div class="timer-display">
269 {format!("{:02}:{:02}", duration / 60, duration % 60)}
270 </div>
271 <button
272 class="timer-reset"
273 type="button"
274 on:click=move |_| {
275 if let Some(callback) = on_reset {
276 callback.run(());
277 }
278 }
279 >
280 "Reset"
281 </button>
282 </div>
283 }
284}
285
286#[component]
288pub fn OtpResend(
289 #[prop(optional)]
291 available: Option<bool>,
292 #[prop(optional)]
294 cooldown: Option<usize>,
295 #[prop(optional)]
297 on_resend: Option<Callback<()>>,
298 #[prop(optional)]
300 class: Option<String>,
301 #[prop(optional)]
303 style: Option<String>,
304) -> impl IntoView {
305 let available = available.unwrap_or(true);
306 let cooldown = cooldown.unwrap_or(0);
307 let class = format!(
308 "otp-resend {} {}",
309 if available {
310 "available"
311 } else {
312 "unavailable"
313 },
314 class.unwrap_or_default()
315 );
316
317 let style = style.unwrap_or_default();
318
319 view! {
320 <div class=class style=style>
321 {if available {
322 view! {
323 <button
324 class="resend-button"
325 type="button"
326 on:click=move |_| {
327 if let Some(callback) = on_resend {
328 callback.run(());
329 }
330 }
331 >
332 "Resend OTP"
333 </button>
334 }.into_any()
335 } else {
336 view! {
337 <span class="cooldown-text">
338 {format!("Resend available in {}s", cooldown)}
339 </span>
340 }.into_any()
341 }}
342 </div>
343 }
344}
345
346fn validate_otp(value: &str, expected_length: usize, input_type: &OtpInputType) -> OtpValidation {
348 let mut errors = Vec::new();
349 let is_complete = value.len() == expected_length;
350 let mut is_valid = true;
351
352 if value.is_empty() {
354 errors.push("OTP is required".to_string());
355 is_valid = false;
356 } else if value.len() < expected_length {
357 errors.push(format!("OTP must be {} digits long", expected_length));
358 is_valid = false;
359 }
360
361 match input_type {
363 OtpInputType::Numeric => {
364 if !value.chars().all(|c| c.is_numeric()) {
365 errors.push("OTP must contain only numbers".to_string());
366 is_valid = false;
367 }
368 }
369 OtpInputType::Alphabetic => {
370 if !value.chars().all(|c| c.is_alphabetic()) {
371 errors.push("OTP must contain only letters".to_string());
372 is_valid = false;
373 }
374 }
375 OtpInputType::Alphanumeric => {
376 if !value.chars().all(|c| c.is_alphanumeric()) {
377 errors.push("OTP must contain only letters and numbers".to_string());
378 is_valid = false;
379 }
380 }
381 }
382
383 if value.len() > 1 && value.chars().all(|c| c == value.chars().next().unwrap()) {
385 errors.push("OTP cannot contain all identical characters".to_string());
386 is_valid = false;
387 }
388
389 OtpValidation {
390 is_valid: is_valid && is_complete,
391 is_complete,
392 length: value.len(),
393 errors,
394 }
395}
396
397#[cfg(test)]
398mod tests {
399 use crate::{OtpInputType, OtpValidation};
400
401 #[test]
403 fn test_otp_field_component_creation() {
404 let result = std::panic::catch_unwind(|| {
407 });
412
413 assert!(
415 result.is_ok(),
416 "OtpField component should be callable with proper props"
417 );
418 }
419
420 #[test]
421 fn test_otp_field_with_validation_component_creation() {
422 let result = std::panic::catch_unwind(|| {
425 });
430
431 assert!(
433 result.is_ok(),
434 "OtpFieldWithValidation component should be callable with proper props"
435 );
436 }
437
438 #[test]
439 fn test_otp_timer_component_creation() {}
440
441 #[test]
442 fn test_otp_resend_component_creation() {}
443
444 #[test]
446 fn test_otp_input_type_enum() {
447 assert_eq!(OtpInputType::Numeric, OtpInputType::default());
448 assert_eq!(OtpInputType::Alphanumeric, OtpInputType::Alphanumeric);
449 assert_eq!(OtpInputType::Alphabetic, OtpInputType::Alphabetic);
450 }
451
452 #[test]
453 fn test_otp_validation_struct() {
454 let validation = OtpValidation {
455 is_valid: true,
456 is_complete: true,
457 length: 6,
458 errors: Vec::new(),
459 };
460 assert!(validation.is_valid);
461 assert!(validation.is_complete);
462 assert_eq!(validation.length, 6);
463 assert!(validation.errors.is_empty());
464 }
465
466 #[test]
467 fn test_otp_validation_default() {
468 let validation = OtpValidation::default();
469 assert!(!validation.is_valid);
470 assert!(!validation.is_complete);
471 assert_eq!(validation.length, 0);
472 assert!(validation.errors.is_empty());
473 }
474
475 #[test]
477 fn test_otp_field_props_handling() {}
478
479 #[test]
480 fn test_otp_field_value_handling() {}
481
482 #[test]
483 fn test_otp_field_length_handling() {}
484
485 #[test]
486 fn test_otp_fielddisabled_state() {}
487
488 #[test]
489 fn test_otp_fieldrequired_state() {}
490
491 #[test]
492 fn test_otp_field_auto_focus() {}
493
494 #[test]
495 fn test_otp_field_auto_submit() {}
496
497 #[test]
498 fn test_otp_field_input_type() {}
499
500 #[test]
502 fn test_otp_field_change_callback() {}
503
504 #[test]
505 fn test_otp_field_complete_callback() {}
506
507 #[test]
508 fn test_otp_field_submit_callback() {}
509
510 #[test]
511 fn test_otp_field_focus_callback() {}
512
513 #[test]
514 fn test_otp_field_blur_callback() {}
515
516 #[test]
517 fn test_otp_field_validation_callback() {}
518
519 #[test]
521 fn test_otp_validation_numeric() {}
522
523 #[test]
524 fn test_otp_validation_alphanumeric() {}
525
526 #[test]
527 fn test_otp_validation_alphabetic() {}
528
529 #[test]
530 fn test_otp_validation_length() {}
531
532 #[test]
533 fn test_otp_validation_duplicate_characters() {}
534
535 #[test]
536 fn test_otp_validation_empty_input() {}
537
538 #[test]
540 fn test_otp_timer_duration() {}
541
542 #[test]
543 fn test_otp_timer_running_state() {}
544
545 #[test]
546 fn test_otp_timer_expire_callback() {}
547
548 #[test]
549 fn test_otp_timer_reset_callback() {}
550
551 #[test]
553 fn test_otp_resend_availability() {}
554
555 #[test]
556 fn test_otp_resend_cooldown() {}
557
558 #[test]
559 fn test_otp_resend_callback() {}
560
561 #[test]
563 fn test_otp_field_accessibility() {}
564
565 #[test]
566 fn test_otp_field_keyboard_navigation() {}
567
568 #[test]
569 fn test_otp_field_screen_reader_support() {}
570
571 #[test]
572 fn test_otp_field_focus_management() {}
573
574 #[test]
576 fn test_otp_field_security() {}
577
578 #[test]
579 fn test_otp_field_input_restrictions() {}
580
581 #[test]
582 fn test_otp_field_autocomplete() {}
583
584 #[test]
586 fn test_otp_field_full_workflow() {}
587
588 #[test]
589 fn test_otp_field_with_timer() {}
590
591 #[test]
592 fn test_otp_field_with_resend() {}
593
594 #[test]
596 fn test_otp_field_empty_input() {}
597
598 #[test]
599 fn test_otp_field_very_long_input() {}
600
601 #[test]
602 fn test_otp_field_special_characters() {}
603
604 #[test]
605 fn test_otp_field_unicode_characters() {}
606
607 #[test]
609 fn test_otp_field_validation_performance() {}
610
611 #[test]
612 fn test_otp_field_rendering_performance() {}
613
614 #[test]
616 fn test_otp_field_custom_classes() {}
617
618 #[test]
619 fn test_otp_field_custom_styles() {}
620
621 #[test]
622 fn test_otp_field_responsive_design() {}
623
624 #[test]
625 fn test_otp_field_input_spacing() {}
626}