dioxus_ui_system/atoms/
number_input.rs1use crate::styles::Style;
6use crate::theme::{use_style, use_theme};
7use dioxus::prelude::*;
8
9#[derive(Props, Clone, PartialEq)]
11pub struct NumberInputProps {
12 #[props(default)]
14 pub value: f64,
15 pub on_change: EventHandler<f64>,
17 #[props(default)]
19 pub min: Option<f64>,
20 #[props(default)]
22 pub max: Option<f64>,
23 #[props(default = 1.0)]
25 pub step: f64,
26 #[props(default)]
28 pub precision: Option<usize>,
29 #[props(default)]
31 pub placeholder: Option<String>,
32 #[props(default)]
34 pub disabled: bool,
35 #[props(default)]
37 pub label: Option<String>,
38 #[props(default)]
40 pub error: Option<String>,
41 #[props(default)]
43 pub style: Option<String>,
44 #[props(default)]
46 pub class: Option<String>,
47}
48
49#[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}