Skip to main content

dioxus_ui_system/atoms/
number_input.rs

1//! Number Input atom component
2//!
3//! An input with +/- buttons for incrementing/decrementing numeric values.
4
5use crate::styles::Style;
6use crate::theme::{use_style, use_theme};
7use dioxus::prelude::*;
8
9/// Number input properties
10#[derive(Props, Clone, PartialEq)]
11pub struct NumberInputProps {
12    /// Current value
13    #[props(default)]
14    pub value: f64,
15    /// Callback when value changes
16    pub on_change: EventHandler<f64>,
17    /// Minimum value
18    #[props(default)]
19    pub min: Option<f64>,
20    /// Maximum value
21    #[props(default)]
22    pub max: Option<f64>,
23    /// Step increment
24    #[props(default = 1.0)]
25    pub step: f64,
26    /// Decimal precision
27    #[props(default)]
28    pub precision: Option<usize>,
29    /// Placeholder text
30    #[props(default)]
31    pub placeholder: Option<String>,
32    /// Disabled state
33    #[props(default)]
34    pub disabled: bool,
35    /// Label text
36    #[props(default)]
37    pub label: Option<String>,
38    /// Error message
39    #[props(default)]
40    pub error: Option<String>,
41    /// Custom inline styles
42    #[props(default)]
43    pub style: Option<String>,
44    /// Custom class name
45    #[props(default)]
46    pub class: Option<String>,
47}
48
49/// Number input component with increment/decrement buttons
50#[component]
51pub fn NumberInput(props: NumberInputProps) -> Element {
52    let _theme = use_theme();
53
54    let container_style = use_style(|_t| Style::new().flex().flex_col().w_full().build());
55
56    let input_wrapper_style = use_style(|t| {
57        Style::new()
58            .flex()
59            .items_center()
60            .border(1, &t.colors.border)
61            .rounded(&t.radius, "md")
62            .bg(&t.colors.background)
63            .overflow_hidden()
64            .build()
65    });
66
67    let button_style = use_style(|t| {
68        Style::new()
69            .flex()
70            .items_center()
71            .justify_center()
72            .w_px(32)
73            .h_full()
74            .bg(&t.colors.muted)
75            .cursor_pointer()
76            .text_color(&t.colors.foreground)
77            .font_size(16)
78            .transition("background 0.15s ease")
79            .build()
80    });
81
82    let input_style = use_style(|t| {
83        Style::new()
84            .flex()
85            .min_w_px(60)
86            .p(&t.spacing, "sm")
87            .text_color(&t.colors.foreground)
88            .font_size(14)
89            .text_align("center")
90            .build()
91    });
92
93    let label_style = use_style(|t| {
94        Style::new()
95            .block()
96            .mb(&t.spacing, "xs")
97            .font_size(14)
98            .font_weight(500)
99            .text_color(&t.colors.foreground)
100            .build()
101    });
102
103    let error_style = use_style(|t| {
104        Style::new()
105            .mt(&t.spacing, "xs")
106            .font_size(12)
107            .text_color(&t.colors.destructive)
108            .build()
109    });
110
111    let handle_increment = move |_| {
112        if props.disabled {
113            return;
114        }
115        let new_value = props.value + props.step;
116        let clamped = if let Some(max) = props.max {
117            new_value.min(max)
118        } else {
119            new_value
120        };
121        let formatted = format_value(clamped, props.precision);
122        props.on_change.call(formatted);
123    };
124
125    let handle_decrement = move |_| {
126        if props.disabled {
127            return;
128        }
129        let new_value = props.value - props.step;
130        let clamped = if let Some(min) = props.min {
131            new_value.max(min)
132        } else {
133            new_value
134        };
135        let formatted = format_value(clamped, props.precision);
136        props.on_change.call(formatted);
137    };
138
139    let handle_input = move |e: Event<FormData>| {
140        if props.disabled {
141            return;
142        }
143        if let Ok(val) = e.value().parse::<f64>() {
144            let clamped = clamp_value(val, props.min, props.max);
145            let formatted = format_value(clamped, props.precision);
146            props.on_change.call(formatted);
147        }
148    };
149
150    let display_value = format_value(props.value, props.precision);
151
152    rsx! {
153        div {
154            style: "{container_style} {props.style.clone().unwrap_or_default()}",
155            class: "{props.class.clone().unwrap_or_default()}",
156
157            if let Some(label) = props.label.clone() {
158                label {
159                    style: "{label_style}",
160                    "{label}"
161                }
162            }
163
164            div {
165                style: "{input_wrapper_style}",
166                opacity: if props.disabled { "0.5" } else { "1" },
167                pointer_events: if props.disabled { "none" } else { "auto" },
168
169                button {
170                    style: "{button_style}",
171                    onclick: handle_decrement,
172                    disabled: props.disabled,
173                    "−"
174                }
175
176                input {
177                    style: "{input_style}",
178                    r#type: "number",
179                    value: "{display_value}",
180                    placeholder: props.placeholder.clone().unwrap_or_default(),
181                    min: props.min.map(|m| m.to_string()).unwrap_or_default(),
182                    max: props.max.map(|m| m.to_string()).unwrap_or_default(),
183                    step: props.step.to_string(),
184                    disabled: props.disabled,
185                    oninput: handle_input,
186                }
187
188                button {
189                    style: "{button_style}",
190                    onclick: handle_increment,
191                    disabled: props.disabled,
192                    "+"
193                }
194            }
195
196            if let Some(error) = props.error.clone() {
197                span {
198                    style: "{error_style}",
199                    "{error}"
200                }
201            }
202        }
203    }
204}
205
206fn clamp_value(value: f64, min: Option<f64>, max: Option<f64>) -> f64 {
207    let mut result = value;
208    if let Some(min) = min {
209        result = result.max(min);
210    }
211    if let Some(max) = max {
212        result = result.min(max);
213    }
214    result
215}
216
217fn format_value(value: f64, precision: Option<usize>) -> f64 {
218    match precision {
219        Some(p) => {
220            let multiplier = 10f64.powi(p as i32);
221            (value * multiplier).round() / multiplier
222        }
223        None => value,
224    }
225}