dioxus_ui_system/molecules/
otp_input.rs1use crate::styles::Style;
7use crate::theme::use_style;
8use dioxus::prelude::*;
9
10#[derive(Props, Clone, PartialEq)]
12pub struct OtpInputProps {
13 #[props(default = 6)]
15 pub length: usize,
16 #[props(default)]
18 pub value: String,
19 pub on_change: EventHandler<String>,
21 #[props(default)]
23 pub disabled: bool,
24 #[props(default)]
26 pub error: bool,
27 #[props(default)]
29 pub mask: bool,
30 #[props(default)]
32 pub auto_focus: bool,
33 #[props(default)]
35 pub style: Option<String>,
36 #[props(default)]
38 pub class: Option<String>,
39 #[props(default)]
41 pub on_complete: Option<EventHandler<String>>,
42}
43
44#[component]
64pub fn OtpInput(props: OtpInputProps) -> Element {
65 let length = props.length.max(1).min(12); let disabled = props.disabled;
67 let error = props.error;
68 let mask = props.mask;
69 let auto_focus = props.auto_focus;
70
71 let mut focused_index = use_signal(|| Option::<usize>::None);
73
74 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 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 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 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 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 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 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 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 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 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 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 let new_code = if val.len() > 1 {
249 handle_paste(idx, &val, ¤t_value_clone)
250 } else {
251 let digit = val.chars().filter(|c| c.is_ascii_digit()).next();
253 if let Some(d) = digit {
254 update_value_at(idx, d, ¤t_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 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 let new_code = clear_value_at(idx, ¤t_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}