adui_dioxus/components/
input_number.rs

1use crate::components::config_provider::use_config;
2use crate::components::control::{ControlStatus, push_status_class};
3use crate::components::form::{FormItemControlContext, use_form_item_control};
4use crate::components::number_utils::{
5    NumberRules, apply_step, parse_and_normalize, round_with_precision,
6};
7use dioxus::events::KeyboardEvent;
8use dioxus::prelude::{Key, *};
9use serde_json::{Number, Value};
10/// Numeric input with step controls and basic formatting.
11#[derive(Props, Clone, PartialEq)]
12pub struct InputNumberProps {
13    /// Controlled value. When set, internal state is ignored.
14    #[props(optional)]
15    pub value: Option<f64>,
16    /// Uncontrolled initial value.
17    #[props(optional)]
18    pub default_value: Option<f64>,
19    #[props(optional)]
20    pub min: Option<f64>,
21    #[props(optional)]
22    pub max: Option<f64>,
23    #[props(optional)]
24    pub step: Option<f64>,
25    #[props(optional)]
26    pub precision: Option<u32>,
27    #[props(default = true)]
28    pub controls: bool,
29    #[props(default)]
30    pub disabled: bool,
31    #[props(optional)]
32    pub status: Option<ControlStatus>,
33    #[props(optional)]
34    pub prefix: Option<Element>,
35    #[props(optional)]
36    pub suffix: Option<Element>,
37    #[props(default)]
38    pub class: Option<String>,
39    #[props(optional)]
40    pub style: Option<String>,
41    /// Fired whenever the numeric value changes. `None` means empty.
42    #[props(optional)]
43    pub on_change: Option<EventHandler<Option<f64>>>,
44    /// Fired on blur or Enter to indicate the current committed value.
45    #[props(optional)]
46    pub on_change_complete: Option<EventHandler<Option<f64>>>,
47}
48
49#[component]
50pub fn InputNumber(props: InputNumberProps) -> Element {
51    let InputNumberProps {
52        value,
53        default_value,
54        min,
55        max,
56        step,
57        precision,
58        controls,
59        disabled,
60        status,
61        prefix,
62        suffix,
63        class,
64        style,
65        on_change,
66        on_change_complete,
67    } = props;
68
69    let config = use_config();
70    let form_control = use_form_item_control();
71    let controlled_by_prop = value.is_some();
72
73    let rules = NumberRules {
74        min,
75        max,
76        step,
77        precision,
78    };
79
80    // Local storage for uncontrolled usage.
81    let inner_value = use_signal(|| default_value);
82    let draft = {
83        let initial = format_value(
84            resolve_current_value(value, &form_control, inner_value.clone()),
85            precision,
86        );
87        use_signal(|| initial)
88    };
89
90    // Keep the displayed text in sync when external value/form changes.
91    {
92        let mut draft_signal = draft.clone();
93        let inner_value_signal = inner_value.clone();
94        let form_control_ctx = form_control.clone();
95        use_effect(move || {
96            let current =
97                resolve_current_value(value, &form_control_ctx, inner_value_signal.clone());
98            draft_signal.set(format_value(current, precision));
99        });
100    }
101
102    let is_disabled =
103        disabled || config.disabled || form_control.as_ref().is_some_and(|ctx| ctx.is_disabled());
104
105    let mut classes = vec!["adui-input-number".to_string()];
106    if is_disabled {
107        classes.push("adui-input-number-disabled".into());
108    }
109    push_status_class(&mut classes, status);
110    if let Some(extra) = class {
111        classes.push(extra);
112    }
113    let class_attr = classes.join(" ");
114    let style_attr = style.unwrap_or_default();
115
116    let mut draft_for_input = draft.clone();
117    let draft_for_blur = draft.clone();
118    let draft_for_keys = draft.clone();
119    let on_change_complete_cb = on_change_complete.clone();
120
121    let form_for_input = form_control.clone();
122    let inner_for_input = inner_value.clone();
123    let on_change_for_input = on_change.clone();
124
125    let form_for_keys = form_control.clone();
126    let inner_for_keys = inner_value.clone();
127    let on_change_for_keys = on_change.clone();
128
129    let form_for_blur = form_control.clone();
130    let inner_for_blur = inner_value.clone();
131    let on_change_for_blur = on_change.clone();
132
133    let form_for_up = form_control.clone();
134    let inner_for_up = inner_value.clone();
135    let on_change_for_up = on_change.clone();
136    let draft_for_up = draft.clone();
137
138    let form_for_down = form_control.clone();
139    let inner_for_down = inner_value.clone();
140    let on_change_for_down = on_change.clone();
141    let draft_for_down = draft.clone();
142
143    let input_value = draft.read().clone();
144
145    rsx! {
146        div { class: "{class_attr}", style: "{style_attr}",
147            if let Some(icon) = prefix {
148                span { class: "adui-input-number-prefix", {icon} }
149            }
150            input {
151                class: "adui-input-number-input",
152                r#type: "text",
153                inputmode: "decimal",
154                disabled: is_disabled,
155                value: "{input_value}",
156                oninput: move |evt| {
157                    let text = evt.value();
158                    *draft_for_input.write() = text.clone();
159
160                    let trimmed = text.trim();
161                    if trimmed.is_empty() {
162                        apply_value(
163                            None,
164                            false,
165                            controlled_by_prop,
166                            inner_for_input.clone(),
167                            &form_for_input,
168                            precision,
169                            draft_for_input.clone(),
170                            &on_change_for_input,
171                        );
172                        return;
173                    }
174
175                    if let Some(parsed) = parse_and_normalize(trimmed, &rules) {
176                        apply_value(
177                            Some(parsed),
178                            false,
179                            controlled_by_prop,
180                            inner_for_input.clone(),
181                            &form_for_input,
182                            precision,
183                            draft_for_input.clone(),
184                            &on_change_for_input,
185                        );
186                    }
187                },
188                onkeydown: move |evt: KeyboardEvent| {
189                    match evt.key() {
190                        Key::ArrowUp => {
191                            let base = resolve_current_value(value, &form_for_keys, inner_for_keys.clone())
192                                .or(rules.min)
193                                .unwrap_or(0.0);
194                            let next = apply_step(base, 1, &rules);
195                            apply_value(
196                                Some(next),
197                                true,
198                                controlled_by_prop,
199                                inner_for_keys.clone(),
200                                &form_for_keys,
201                                precision,
202                                draft_for_keys.clone(),
203                                &on_change_for_keys,
204                            );
205                        }
206                        Key::ArrowDown => {
207                            let base = resolve_current_value(value, &form_for_keys, inner_for_keys.clone())
208                                .or(rules.min)
209                                .unwrap_or(0.0);
210                            let next = apply_step(base, -1, &rules);
211                            apply_value(
212                                Some(next),
213                                true,
214                                controlled_by_prop,
215                                inner_for_keys.clone(),
216                                &form_for_keys,
217                                precision,
218                                draft_for_keys.clone(),
219                                &on_change_for_keys,
220                            );
221                        }
222                        Key::Enter => {
223                            let current_text = draft.read().clone();
224                            let normalized = if current_text.trim().is_empty() {
225                                None
226                            } else {
227                                parse_and_normalize(&current_text, &rules)
228                            };
229                            apply_value(
230                                normalized,
231                                true,
232                                controlled_by_prop,
233                                inner_for_keys.clone(),
234                                &form_for_keys,
235                                precision,
236                                draft_for_keys.clone(),
237                                &on_change_for_keys,
238                            );
239                            if let Some(cb) = on_change_complete_cb.as_ref() {
240                                cb.call(normalized);
241                            }
242                        }
243                        _ => {}
244                    }
245                },
246                onblur: move |_| {
247                    let current_text = draft_for_blur.read().clone();
248                    let normalized = if current_text.trim().is_empty() {
249                        None
250                    } else {
251                        parse_and_normalize(&current_text, &rules)
252                    };
253                    apply_value(
254                        normalized,
255                        true,
256                        controlled_by_prop,
257                        inner_for_blur.clone(),
258                        &form_for_blur,
259                        precision,
260                        draft_for_blur.clone(),
261                        &on_change_for_blur,
262                    );
263                    if let Some(cb) = on_change_complete_cb.as_ref() {
264                        cb.call(normalized);
265                    }
266                },
267            }
268            if let Some(icon) = suffix {
269                span { class: "adui-input-number-suffix", {icon} }
270            }
271            if controls {
272                div { class: "adui-input-number-handlers",
273                    button {
274                        class: "adui-input-number-handler adui-input-number-handler-up",
275                        disabled: is_disabled,
276                        onclick: move |_| {
277                            let base = resolve_current_value(value, &form_for_up, inner_for_up.clone())
278                                .or(rules.min)
279                                .unwrap_or(0.0);
280                            let next = apply_step(base, 1, &rules);
281                            apply_value(
282                                Some(next),
283                                true,
284                                controlled_by_prop,
285                                inner_for_up.clone(),
286                                &form_for_up,
287                                precision,
288                                draft_for_up.clone(),
289                                &on_change_for_up,
290                            );
291                        },
292                        "▲"
293                    }
294                    button {
295                        class: "adui-input-number-handler adui-input-number-handler-down",
296                        disabled: is_disabled,
297                        onclick: move |_| {
298                            let base = resolve_current_value(value, &form_for_down, inner_for_down.clone())
299                                .or(rules.min)
300                                .unwrap_or(0.0);
301                            let next = apply_step(base, -1, &rules);
302                            apply_value(
303                                Some(next),
304                                true,
305                                controlled_by_prop,
306                                inner_for_down.clone(),
307                                &form_for_down,
308                                precision,
309                                draft_for_down.clone(),
310                                &on_change_for_down,
311                            );
312                        },
313                        "▼"
314                    }
315                }
316            }
317        }
318    }
319}
320
321#[allow(clippy::too_many_arguments)]
322fn apply_value(
323    next: Option<f64>,
324    normalize_display: bool,
325    controlled_by_prop: bool,
326    inner: Signal<Option<f64>>,
327    form_control: &Option<FormItemControlContext>,
328    precision: Option<u32>,
329    draft: Signal<String>,
330    on_change: &Option<EventHandler<Option<f64>>>,
331) {
332    if let Some(ctx) = form_control.as_ref() {
333        let value_to_set = match next.and_then(Number::from_f64) {
334            Some(num) => Value::Number(num),
335            None => Value::Null,
336        };
337        ctx.set_value(value_to_set);
338    } else if !controlled_by_prop {
339        let mut state = inner;
340        state.set(next);
341    }
342
343    if let Some(cb) = on_change.as_ref() {
344        cb.call(next);
345    }
346
347    if normalize_display {
348        let mut d = draft;
349        d.set(format_value(next, precision));
350    }
351}
352
353fn format_value(value: Option<f64>, precision: Option<u32>) -> String {
354    match value {
355        None => String::new(),
356        Some(v) => {
357            if let Some(p) = precision {
358                format!("{:.*}", p as usize, round_with_precision(v, Some(p)))
359            } else {
360                let mut text = v.to_string();
361                if text.contains('.') {
362                    while text.ends_with('0') {
363                        text.pop();
364                    }
365                    if text.ends_with('.') {
366                        text.pop();
367                    }
368                }
369                text
370            }
371        }
372    }
373}
374
375fn resolve_current_value(
376    value: Option<f64>,
377    form_control: &Option<crate::components::form::FormItemControlContext>,
378    inner: Signal<Option<f64>>,
379) -> Option<f64> {
380    value
381        .or_else(|| {
382            form_control
383                .as_ref()
384                .and_then(|ctx| value_from_form(ctx.value()))
385        })
386        .or_else(|| *inner.read())
387}
388
389fn value_from_form(val: Option<Value>) -> Option<f64> {
390    match val {
391        Some(Value::Number(n)) => n.as_f64(),
392        Some(Value::String(s)) => s.parse::<f64>().ok(),
393        Some(Value::Bool(b)) => Some(if b { 1.0 } else { 0.0 }),
394        _ => None,
395    }
396}
397
398#[cfg(test)]
399mod input_number_tests {
400    use super::*;
401    use serde_json::Number;
402
403    #[test]
404    fn input_number_props_defaults() {
405        // Test default values
406        assert_eq!(
407            InputNumberProps {
408                value: None,
409                default_value: None,
410                min: None,
411                max: None,
412                step: None,
413                precision: None,
414                controls: true,
415                disabled: false,
416                status: None,
417                prefix: None,
418                suffix: None,
419                class: None,
420                style: None,
421                on_change: None,
422                on_change_complete: None,
423            }
424            .controls,
425            true
426        );
427    }
428
429    #[test]
430    fn format_value_none() {
431        assert_eq!(format_value(None, None), "");
432    }
433
434    #[test]
435    fn format_value_with_precision() {
436        let result = format_value(Some(3.14159), Some(2));
437        assert_eq!(result, "3.14");
438    }
439
440    #[test]
441    fn format_value_without_precision() {
442        let result = format_value(Some(3.0), None);
443        assert_eq!(result, "3");
444    }
445
446    #[test]
447    fn value_from_form_number() {
448        let num = Number::from_f64(42.5).unwrap();
449        assert_eq!(value_from_form(Some(Value::Number(num))), Some(42.5));
450    }
451
452    #[test]
453    fn value_from_form_string() {
454        assert_eq!(
455            value_from_form(Some(Value::String("42.5".to_string()))),
456            Some(42.5)
457        );
458    }
459
460    #[test]
461    fn value_from_form_bool() {
462        assert_eq!(value_from_form(Some(Value::Bool(true))), Some(1.0));
463        assert_eq!(value_from_form(Some(Value::Bool(false))), Some(0.0));
464    }
465
466    #[test]
467    fn value_from_form_none() {
468        assert_eq!(value_from_form(None), None);
469        assert_eq!(value_from_form(Some(Value::Null)), None);
470    }
471}