Skip to main content

dioxus_ui_system/molecules/
otp_input.rs

1//! OTP Input molecule component
2//!
3//! A configurable multi-digit code input with individual boxes for each character.
4//! Supports auto-focus navigation, paste functionality, and masking.
5
6use crate::styles::Style;
7use crate::theme::use_style;
8use dioxus::prelude::*;
9
10/// OTP Input properties
11#[derive(Props, Clone, PartialEq)]
12pub struct OtpInputProps {
13    /// Number of input boxes (default: 6)
14    #[props(default = 6)]
15    pub length: usize,
16    /// Current value
17    #[props(default)]
18    pub value: String,
19    /// Change handler called when the full code is entered or modified
20    pub on_change: EventHandler<String>,
21    /// Disabled state
22    #[props(default)]
23    pub disabled: bool,
24    /// Error state styling
25    #[props(default)]
26    pub error: bool,
27    /// Mask input (show • instead of numbers)
28    #[props(default)]
29    pub mask: bool,
30    /// Auto-focus first input on mount
31    #[props(default)]
32    pub auto_focus: bool,
33    /// Custom inline styles for the container
34    #[props(default)]
35    pub style: Option<String>,
36    /// Custom class name
37    #[props(default)]
38    pub class: Option<String>,
39    /// Callback when input is completed (all digits filled)
40    #[props(default)]
41    pub on_complete: Option<EventHandler<String>>,
42}
43
44/// OTP Input molecule component
45///
46/// # Example
47/// ```rust,ignore
48/// use dioxus_ui_system::molecules::OtpInput;
49///
50/// let mut code = use_signal(|| String::new());
51///
52/// rsx! {
53///     OtpInput {
54///         length: 6,
55///         value: code(),
56///         on_change: move |v| code.set(v),
57///         on_complete: Some(EventHandler::new(move |v: String| {
58///             println!("Code complete: {}", v);
59///         })),
60///     }
61/// }
62/// ```
63#[component]
64pub fn OtpInput(props: OtpInputProps) -> Element {
65    let length = props.length.max(1).min(12); // Clamp between 1 and 12
66    let disabled = props.disabled;
67    let error = props.error;
68    let mask = props.mask;
69    let auto_focus = props.auto_focus;
70
71    // Track which input is focused (for styling)
72    let mut focused_index = use_signal(|| Option::<usize>::None);
73
74    // Container style
75    let container_style = use_style(|_| {
76        Style::new()
77            .flex()
78            .gap_px(8)
79            .items_center()
80            .justify_center()
81            .build()
82    });
83
84    // Individual input box style
85    let input_style = use_style(move |t| {
86        let base = Style::new()
87            .w_px(48)
88            .h_px(56)
89            .min_w_px(40)
90            .rounded(&t.radius, "md")
91            .border(
92                1,
93                if error {
94                    &t.colors.destructive
95                } else {
96                    &t.colors.border
97                },
98            )
99            .bg(&t.colors.background)
100            .text_color(&t.colors.foreground)
101            .text_center()
102            .font_size(20)
103            .font_weight(600)
104            .transition("all 150ms cubic-bezier(0.4, 0, 0.2, 1)")
105            .outline("none");
106
107        // Responsive sizing
108        let base = base
109            .w("calc(40px + 2vw)")
110            .h("calc(48px + 1vh)")
111            .min_w_px(36)
112            .min_h_px(44);
113
114        // Disabled state
115        let base = if disabled {
116            base.cursor("not-allowed").opacity(0.5).bg(&t.colors.muted)
117        } else {
118            base.cursor("text")
119        };
120
121        // Error state shadow
122        if error {
123            Style {
124                box_shadow: Some(format!("0 0 0 1px {}", t.colors.destructive.to_rgba())),
125                ..base
126            }
127            .build()
128        } else {
129            base.build()
130        }
131    });
132
133    // Focus style (applied dynamically)
134    let get_focus_style = use_style(move |t| {
135        Style::new()
136            .border_color(&t.colors.ring)
137            .shadow(&format!(
138                "0 0 0 2px {}",
139                t.colors
140                    .ring
141                    .to_rgba()
142                    .replace(')', ", 0.3)")
143                    .replace("rgba", "rgb")
144            ))
145            .build()
146    });
147
148    let custom_style = props.style.clone().unwrap_or_default();
149    let class = props.class.clone().unwrap_or_default();
150    let on_change = props.on_change.clone();
151    let on_complete = props.on_complete.clone();
152    let current_value = props.value.clone();
153
154    // Helper to check if all digits are filled and call on_complete
155    let check_complete = move |code: &str| {
156        if code.len() == length && code.chars().all(|c| c.is_ascii_digit()) {
157            if let Some(ref handler) = on_complete {
158                handler.call(code.to_string());
159            }
160        }
161    };
162
163    rsx! {
164        div {
165            style: "{container_style} {custom_style}",
166            class: "{class}",
167            role: "group",
168            "aria-label": "One-time code input",
169
170            for index in 0..length {
171                {
172                    let idx = index;
173                    let value = current_value.chars().nth(idx).unwrap_or(' ');
174                    let display_value = if value.is_ascii_digit() {
175                        if mask { "•".to_string() } else { value.to_string() }
176                    } else {
177                        String::new()
178                    };
179
180                    let is_focused = focused_index() == Some(idx);
181                    let focus_style = if is_focused && !disabled { get_focus_style() } else { String::new() };
182                    let should_auto_focus = auto_focus && idx == 0;
183
184                    let aria_label = format!("Digit {} of {}", idx + 1, length);
185
186                    // Clone values for closures
187                    let on_change_clone = on_change.clone();
188                    let on_change_clone2 = on_change.clone();
189                    let current_value_clone = current_value.clone();
190                    let current_value_clone2 = current_value.clone();
191
192                    rsx! {
193                        input {
194                            key: "otp-{idx}",
195                            id: "otp-input-{idx}",
196                            r#type: "text",
197                            inputmode: "numeric",
198                            pattern: "[0-9]*",
199                            autocomplete: if idx == 0 { "one-time-code" } else { "off" },
200                            maxlength: "1",
201                            disabled: disabled,
202                            value: "{display_value}",
203                            autofocus: should_auto_focus,
204                            "aria-label": "{aria_label}",
205                            "aria-disabled": disabled,
206                            "aria-invalid": error,
207                            style: "{input_style} {focus_style}",
208                            oninput: move |e| {
209                                let val = e.value();
210
211                                // Helper to update value at specific index
212                                let update_value_at = |index: usize, digit: char, base_value: &str| -> String {
213                                    let mut current: Vec<char> = base_value.chars().collect();
214                                    while current.len() < length {
215                                        current.push(' ');
216                                    }
217                                    if index < length {
218                                        current[index] = digit;
219                                    }
220                                    current.iter().take(length).collect::<String>().trim().to_string()
221                                };
222
223                                // Handle paste of multiple digits
224                                let handle_paste = |start_index: usize, pasted: &str, base_value: &str| -> String {
225                                    let digits: String = pasted.chars().filter(|c| c.is_ascii_digit()).collect();
226
227                                    if !digits.is_empty() {
228                                        let mut current: Vec<char> = base_value.chars().collect();
229                                        while current.len() < length {
230                                            current.push(' ');
231                                        }
232
233                                        // Fill from start index
234                                        for (i, d) in digits.chars().enumerate() {
235                                            let target_index = start_index + i;
236                                            if target_index < length {
237                                                current[target_index] = d;
238                                            }
239                                        }
240
241                                        current.iter().take(length).collect::<String>().trim().to_string()
242                                    } else {
243                                        base_value.to_string()
244                                    }
245                                };
246
247                                // Check if it looks like a paste (multiple characters)
248                                let new_code = if val.len() > 1 {
249                                    handle_paste(idx, &val, &current_value_clone)
250                                } else {
251                                    // Only accept single digit
252                                    let digit = val.chars().filter(|c| c.is_ascii_digit()).next();
253                                    if let Some(d) = digit {
254                                        update_value_at(idx, d, &current_value_clone)
255                                    } else {
256                                        current_value_clone.clone()
257                                    }
258                                };
259
260                                on_change_clone.call(new_code.clone());
261                                check_complete(&new_code);
262                            },
263                            onkeydown: move |e: Event<dioxus::html::KeyboardData>| {
264                                use dioxus::html::input_data::keyboard_types::Key;
265
266                                // Helper to clear value at specific index
267                                let clear_value_at = |index: usize, base_value: &str| -> String {
268                                    let mut current: Vec<char> = base_value.chars().collect();
269                                    while current.len() < length {
270                                        current.push(' ');
271                                    }
272                                    if index < length {
273                                        current[index] = ' ';
274                                    }
275                                    current.iter().take(length).collect::<String>().trim().to_string()
276                                };
277
278                                match e.key() {
279                                    Key::Backspace => {
280                                        let current_value_char = current_value_clone2.chars().nth(idx).unwrap_or(' ');
281
282                                        if current_value_char != ' ' && current_value_char != '\0' {
283                                            // Clear current value
284                                            let new_code = clear_value_at(idx, &current_value_clone2);
285                                            on_change_clone2.call(new_code);
286                                        }
287                                    }
288                                    _ => {}
289                                }
290                            },
291                            onfocus: move |_| {
292                                focused_index.set(Some(idx));
293                            },
294                            onblur: move |_| {
295                                if focused_index() == Some(idx) {
296                                    focused_index.set(None);
297                                }
298                            },
299                        }
300                    }
301                }
302            }
303        }
304    }
305}