Skip to main content

euv_ui/component/number_input/view/
fn.rs

1use super::*;
2
3/// The step size used when the caller asks for a step of zero.
4///
5/// A zero step would leave the value where it is, which makes the steppers
6/// look broken, so it is normalised to one.
7const FALLBACK_STEP: i64 = 1;
8
9/// The glyph drawn on the increment stepper.
10const STEP_UP_GLYPH: &str = "+";
11
12/// The glyph drawn on the decrement stepper.
13const STEP_DOWN_GLYPH: &str = "\u{2212}";
14
15/// Returns the value a stepper button would write, or `None` when the
16/// stepper is inactive.
17///
18/// A stepper is inactive when the step would carry the value past `min` or
19/// `max`; an unbounded direction never blocks a step. The returned value is
20/// clamped into the bounds, so a step that lands exactly on a bound is
21/// allowed while one that overshoots it is refused.
22///
23/// # Arguments
24///
25/// - `i64` - The current value of the field.
26/// - `Option<i64>` - The inclusive lower bound, or `None` for no bound.
27///
28/// # Returns
29///
30/// - `Option<i64>` - The clamped value the stepper writes, or `None` when
31///   the stepper must stay inert.
32pub fn stepped_value(current: i64, step: i64, min: Option<i64>, max: Option<i64>) -> Option<i64> {
33    let step: i64 = if step == 0 { FALLBACK_STEP } else { step };
34    let candidate: i64 = current.saturating_add(step);
35    let blocked: bool = if step > 0 {
36        max.is_some_and(|max_value: i64| candidate > max_value)
37    } else {
38        min.is_some_and(|min_value: i64| candidate < min_value)
39    };
40    if blocked {
41        return None;
42    }
43    let lowered: i64 = match min {
44        Some(min_value) => candidate.max(min_value),
45        None => candidate,
46    };
47    Some(match max {
48        Some(max_value) => lowered.min(max_value),
49        None => lowered,
50    })
51}
52
53/// Returns whether a stepper button has to be rendered as inert.
54///
55/// The stepper is inert when the step would carry the value past one of its
56/// bounds, which is exactly the case where [`stepped_value`] yields `None`.
57///
58/// # Arguments
59///
60/// - `i64` - The current value of the field.
61/// - `Option<i64>` - The inclusive lower bound, or `None` for no bound.
62///
63/// # Returns
64///
65/// - `bool` - `true` when the stepper cannot move the value.
66pub fn stepper_disabled(current: i64, step: i64, min: Option<i64>, max: Option<i64>) -> bool {
67    stepped_value(current, step, min, max).is_none()
68}
69
70/// Builds the click handler of a stepper button.
71///
72/// The handler reads the current value from the signal, so it always steps
73/// from the value the user sees, and it writes the clamped result back into
74/// the same signal before notifying the caller.
75///
76/// # Arguments
77///
78/// - `Signal<i64>` - The signal holding the current value.
79/// - `i64` - The signed step size.
80/// - `Option<i64>` - The inclusive lower bound, or `None` for no bound.
81/// - `Option<Rc<dyn Fn(i64)>>` - The callback receiving the new value.
82///
83/// # Returns
84///
85/// - `Option<Rc<dyn Fn(Event)>>` - The click handler of the stepper.
86fn on_stepper_click(
87    value: Signal<i64>,
88    step: i64,
89    min: Option<i64>,
90    max: Option<i64>,
91    on_change: Option<Rc<dyn Fn(i64)>>,
92) -> Option<Rc<dyn Fn(Event)>> {
93    Some(Rc::new(move |_event: Event| {
94        let current: i64 = value.get();
95        let Some(next) = stepped_value(current, step, min, max) else {
96            return;
97        };
98        value.set(next);
99        if let Some(callback) = &on_change {
100            callback(next);
101        }
102    }))
103}
104
105/// Builds the change handler of the number field.
106///
107/// The handler reads the value off the input element rather than trusting a
108/// captured value, so a browser that normalises the typed text still lands
109/// on the number the user entered. Text that does not parse as an integer is
110/// ignored, which leaves the previous value in place.
111///
112/// # Arguments
113///
114/// - `Signal<i64>` - The signal holding the current value.
115/// - `Option<Rc<dyn Fn(i64)>>` - The callback receiving the new value.
116///
117/// # Returns
118///
119/// - `Option<Rc<dyn Fn(Event)>>` - The change handler of the field.
120fn on_number_change(
121    value: Signal<i64>,
122    on_change: Option<Rc<dyn Fn(i64)>>,
123) -> Option<Rc<dyn Fn(Event)>> {
124    Some(Rc::new(move |event: Event| {
125        let reported: Option<String> = event.target().and_then(|target: EventTarget| {
126            if let Ok(input) = target.dyn_into::<HtmlInputElement>() {
127                return Some(input.value());
128            }
129            None
130        });
131        let Some(reported) = reported else {
132            return;
133        };
134        let Ok(parsed) = reported.trim().parse::<i64>() else {
135            return;
136        };
137        value.set(parsed);
138        if let Some(callback) = &on_change {
139            callback(parsed);
140        }
141    }))
142}
143
144/// A numeric field with a pair of stepper buttons.
145///
146/// The component renders a `c_euv_number_input` wrapper holding a real
147/// `<input type="number">` and the two trailing steppers. The native spinner
148/// is hidden by the stylesheet, so the steppers are the only way to move the
149/// value by mouse; the field itself still accepts typed numbers.
150///
151/// The value lives in a caller-owned [`Signal`], so the component takes part
152/// in two-way binding: a stepper click and a typed number both write into
153/// the signal and notify `on_change`. A stepper that would carry the value
154/// past `min` or `max` carries `c_euv_number_input_stepper_disabled` and
155/// does nothing when clicked.
156///
157/// # Arguments
158///
159/// - `VirtualNode<EuvNumberInputProps>` - The props node carrying the value,
160///   the bounds, the step size and the change callback.
161///
162/// # Returns
163///
164/// - `VirtualNode` - The number field with its two steppers.
165#[component]
166pub fn euv_number_input(node: VirtualNode<EuvNumberInputProps>) -> VirtualNode {
167    let EuvNumberInputProps {
168        value,
169        min,
170        max,
171        step,
172        label: label_text,
173        disabled,
174        on_change,
175    }: EuvNumberInputProps = node.try_get_props().unwrap_or_default();
176    let step_size: i64 = if step == 0 { FALLBACK_STEP } else { step };
177    let increment: i64 = step_size;
178    let decrement: i64 = -step_size;
179    html! {
180        div {
181            class: c_euv_input_wrapper()
182            if !label_text.is_empty() {
183                label {
184                    class: c_form_label()
185                    label_text
186                }
187            }
188            div {
189                class: c_euv_number_input()
190                input {
191                    type: "number"
192                    value: value.get().to_string()
193                    disabled: disabled
194                    class: c_euv_number_input_field()
195                    onchange: on_number_change(value, on_change.clone())
196                }
197                button {
198                    type: "button"
199                    class: if { stepper_disabled(value.get(), increment, min, max) || disabled } {
200                        c_euv_number_input_stepper_disabled()
201                    } else {
202                        c_euv_number_input_stepper()
203                    }
204                    onclick: on_stepper_click(value, increment, min, max, on_change.clone())
205                    STEP_UP_GLYPH
206                }
207                button {
208                    type: "button"
209                    class: if { stepper_disabled(value.get(), decrement, min, max) || disabled } {
210                        c_euv_number_input_stepper_disabled()
211                    } else {
212                        c_euv_number_input_stepper()
213                    }
214                    onclick: on_stepper_click(value, decrement, min, max, on_change.clone())
215                    STEP_DOWN_GLYPH
216                }
217            }
218        }
219    }
220}