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}