use dioxus::prelude::*;
#[cfg(feature = "icon")]
use crate::icon::{Icon, IconKind};
#[must_use]
pub fn format_and_clamp_number(
val: f64,
min: Option<f64>,
max: Option<f64>,
precision: usize,
) -> f64 {
let mut clamped = val;
if let Some(min_v) = min {
if clamped < min_v {
clamped = min_v;
}
}
if let Some(max_v) = max {
if clamped > max_v {
clamped = max_v;
}
}
let factor = 10f64.powi(precision as i32);
(clamped * factor).round() / factor
}
#[must_use]
pub fn format_number_string(val: f64, precision: usize) -> String {
if precision == 0 {
format!("{:.0}", val.round())
} else {
format!("{val:.precision$}")
}
}
#[component]
pub fn NumberInput(
value: Signal<f64>,
#[props(default = 1.0)] step: f64,
#[props(default)] min: Option<f64>,
#[props(default)] max: Option<f64>,
#[props(default = 0)] precision: usize,
#[props(default)] prefix: Option<String>,
#[props(default)] suffix: Option<String>,
#[props(default)] label: Option<String>,
#[props(default)] placeholder: Option<String>,
#[props(default)] help_text: Option<String>,
#[props(default)] error: Option<String>,
#[props(default)] disabled: bool,
#[props(default)] glow: Option<bool>,
#[props(default)] id: Option<String>,
#[props(default)] class: String,
#[props(default)] on_change: Option<EventHandler<f64>>,
) -> Element {
let input_id = crate::a11y::use_unique_id("sd-number-input", id);
let help_id = if help_text.is_some() {
Some(format!("{input_id}-help"))
} else {
None
};
let err_id = if error.is_some() {
Some(format!("{input_id}-err"))
} else {
None
};
let described_by = match (&help_id, &err_id) {
(Some(h), Some(e)) => Some(format!("{h} {e}")),
(Some(h), None) => Some(h.clone()),
(None, Some(e)) => Some(e.clone()),
(None, None) => None,
};
#[cfg(feature = "theme")]
let theme = crate::theme::use_theme();
#[cfg(feature = "theme")]
let theme_val = theme.read();
#[cfg(feature = "theme")]
let radius_class = theme_val.radius_scale.md();
#[cfg(feature = "theme")]
let base_override = theme_val.resolve_component_base("number_input").unwrap_or("");
#[cfg(feature = "theme")]
let class_modifiers = theme_val.resolve_class_modifiers("number_input", &class);
#[cfg(feature = "theme")]
let glow_class = theme_val.resolve_elevation_class("number_input", glow);
#[cfg(feature = "theme")]
let error_class = if error.is_some() {
theme_val.input_border_class(true)
} else {
theme_val.input_border_class(false)
};
#[cfg(not(feature = "theme"))]
let base_override = "";
#[cfg(not(feature = "theme"))]
let class_modifiers = String::new();
#[cfg(not(feature = "theme"))]
let radius_class = "";
#[cfg(not(feature = "theme"))]
let glow_class = if glow.unwrap_or(false) {
"sd-number-input--glow".to_string()
} else {
String::new()
};
#[cfg(not(feature = "theme"))]
let error_class = if error.is_some() {
"sd-number-input--error"
} else {
""
};
let current_val = *value.read();
let display_str = format_number_string(current_val, precision);
let can_increment = !disabled && max.is_none_or(|m| current_val + step <= m + 1e-9);
let can_decrement = !disabled && min.is_none_or(|m| current_val - step >= m - 1e-9);
let mut do_update = move |new_val: f64| {
let clamped = format_and_clamp_number(new_val, min, max, precision);
*value.write() = clamped;
if let Some(handler) = on_change {
handler.call(clamped);
}
};
let do_increment = {
let mut do_update = do_update;
move || {
if can_increment {
let current = *value.read();
do_update(current + step);
}
}
};
let do_decrement = {
let mut do_update = do_update;
move || {
if can_decrement {
let current = *value.read();
do_update(current - step);
}
}
};
let disabled_class = if disabled { "sd-number-input--disabled" } else { "" };
let up_disabled_class = if !can_increment { "sd-number-input__stepper--disabled" } else { "" };
let down_disabled_class = if !can_decrement { "sd-number-input__stepper--disabled" } else { "" };
let is_err = error.is_some();
rsx! {
div {
class: "sd-number-input-wrapper {class_modifiers} {class}",
if let Some(lbl) = &label {
label {
r#for: "{input_id}",
class: "sd-number-input-label",
"{lbl}"
}
}
div {
class: "sd-number-input-container {base_override} {error_class} {radius_class} {glow_class} {disabled_class}",
if let Some(pref) = &prefix {
span { class: "sd-number-input__prefix", "{pref}" }
}
input {
r#type: "text",
id: "{input_id}",
role: "spinbutton",
"aria-valuenow": "{current_val}",
"aria-valuemin": min.map(|v| v.to_string()).unwrap_or_default(),
"aria-valuemax": max.map(|v| v.to_string()).unwrap_or_default(),
"aria-invalid": if is_err { "true" } else { "false" },
"aria-describedby": described_by.as_deref().unwrap_or(""),
disabled,
placeholder: placeholder.as_deref().unwrap_or_default(),
class: "sd-number-input__field",
value: "{display_str}",
oninput: move |e| {
let text = e.value();
if let Ok(parsed) = text.trim().parse::<f64>() {
do_update(parsed);
} else if text.trim().is_empty() {
let fallback = min.unwrap_or(0.0);
do_update(fallback);
}
},
onkeydown: {
let mut do_increment = do_increment;
let mut do_decrement = do_decrement;
move |e| {
match e.key() {
Key::ArrowUp => {
e.prevent_default();
do_increment();
}
Key::ArrowDown => {
e.prevent_default();
do_decrement();
}
_ => {}
}
}
},
}
if let Some(suff) = &suffix {
span { class: "sd-number-input__suffix", "{suff}" }
}
div {
class: "sd-number-input__steppers",
button {
r#type: "button",
tabindex: "-1",
disabled: !can_increment,
"aria-label": "Incrementa",
class: "sd-number-input__stepper sd-number-input__stepper--up {up_disabled_class}",
onclick: {
let mut do_increment = do_increment;
move |e| {
e.stop_propagation();
do_increment();
}
},
Icon { kind: IconKind::ChevronUp, size: 11 }
}
button {
r#type: "button",
tabindex: "-1",
disabled: !can_decrement,
"aria-label": "Decrementa",
class: "sd-number-input__stepper sd-number-input__stepper--down {down_disabled_class}",
onclick: {
let mut do_decrement = do_decrement;
move |e| {
e.stop_propagation();
do_decrement();
}
},
Icon { kind: IconKind::ChevronDown, size: 11 }
}
}
}
if let Some(err) = &error {
p {
id: err_id.as_deref().unwrap_or(""),
class: "sd-number-input-error",
"{err}"
}
} else if let Some(help) = &help_text {
p {
id: help_id.as_deref().unwrap_or(""),
class: "sd-number-input-help",
"{help}"
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_and_clamp_within_bounds() {
let val = format_and_clamp_number(5.456, Some(0.0), Some(10.0), 2);
assert!((val - 5.46).abs() < 1e-6);
}
#[test]
fn test_format_and_clamp_under_min() {
let val = format_and_clamp_number(-5.0, Some(0.0), Some(10.0), 1);
assert!((val - 0.0).abs() < 1e-6);
}
#[test]
fn test_format_and_clamp_over_max() {
let val = format_and_clamp_number(15.0, Some(0.0), Some(10.0), 0);
assert!((val - 10.0).abs() < 1e-6);
}
#[test]
fn test_format_number_string_precision() {
assert_eq!(format_number_string(42.0, 0), "42");
assert_eq!(format_number_string(42.1234, 2), "42.12");
assert_eq!(format_number_string(42.5, 1), "42.5");
}
}