Skip to main content

hypersig_ui/
number.rs

1//! Number fields with a label, a description, and an error message.
2
3use std::fmt;
4
5use hypersig::html::{Bindable, El, input};
6use hypersig::style::value::rem;
7use hypersig::{Form, IntoExpr, Sig, Validate};
8
9use crate::field::{Frame, field_style};
10
11/// A number type a [`NumberField`] can be bound to: `i32` or `f64`.
12///
13/// The browser's number input and the runtime's binding read these two, so
14/// the trait is sealed.
15#[diagnostic::on_unimplemented(
16    message = "a number field cannot be bound to a `{Self}` signal",
17    note = "bind an `i32` or `f64` signal; text goes in a `TextField`"
18)]
19pub trait Numeric: Bindable + Copy + PartialOrd + fmt::Display + sealed::Sealed {}
20
21impl Numeric for i32 {}
22impl Numeric for f64 {}
23
24mod sealed {
25    pub trait Sealed {
26        /// Zero, which a step must exceed.
27        const ZERO: Self;
28        /// The `step` a field without one gets, where the browser's own
29        /// default of 1 would reject a value the type holds.
30        const DEFAULT_STEP: Option<&'static str>;
31        /// The `inputmode` of a field that takes no negative value.
32        const NON_NEGATIVE_MODE: &'static str;
33        /// Whether the value is a number, which an `f64` NaN is not.
34        fn is_number(&self) -> bool;
35    }
36
37    impl Sealed for i32 {
38        const ZERO: Self = 0;
39        const DEFAULT_STEP: Option<&'static str> = None;
40        const NON_NEGATIVE_MODE: &'static str = "numeric";
41        fn is_number(&self) -> bool {
42            true
43        }
44    }
45
46    impl Sealed for f64 {
47        const ZERO: Self = 0.0;
48        const DEFAULT_STEP: Option<&'static str> = Some("any");
49        const NON_NEGATIVE_MODE: &'static str = "decimal";
50        fn is_number(&self) -> bool {
51            !self.is_nan()
52        }
53    }
54}
55
56/// A number field bound to an `i32` or `f64` signal, with its label and,
57/// where given, a description and an error message tied to it.
58///
59/// It is an `<input type="number">` with the ids, `aria-describedby`, and
60/// `aria-invalid` of a [`TextField`](crate::TextField). `min`, `max`, and
61/// `step` take the field's own type, so a bound cannot be text. A field
62/// whose `min` is 0 or more asks for a keyboard of digits, which on a phone
63/// has no minus sign. An `f64` field without a step takes any decimal.
64///
65/// ```
66/// use hypersig::Signals;
67/// use hypersig_ui::NumberField;
68/// use serde::{Deserialize, Serialize};
69///
70/// #[derive(Signals, Serialize, Deserialize)]
71/// struct Cart {
72///     quantity: i32,
73/// }
74///
75/// let quantity = NumberField::new("Quantity", Cart::quantity()).min(1).max(10);
76/// let html = quantity.render().render();
77/// assert!(html.contains(r#"type="number""#));
78/// assert!(html.contains(r#"min="1""#));
79/// assert!(html.contains(r#"inputmode="numeric""#));
80/// assert!(html.contains(r#"for="ui-quantity-input""#));
81/// ```
82#[derive(Debug)]
83pub struct NumberField<S, T> {
84    value: Sig<S, T>,
85    min: Option<T>,
86    max: Option<T>,
87    step: Option<T>,
88    frame: Frame<S>,
89}
90
91impl<S, T: Numeric> NumberField<S, T> {
92    /// A number field labeled `label`, bound to `value`.
93    #[must_use]
94    pub fn new(label: impl Into<String>, value: Sig<S, T>) -> Self {
95        Self {
96            value,
97            min: None,
98            max: None,
99            step: None,
100            frame: Frame::new(label.into()),
101        }
102    }
103
104    /// A number field labeled `label`, bound to `field` of `form`.
105    ///
106    /// It shows [`error`](Sig::error) of the form for `field` and marks the
107    /// field touched when the visitor leaves it, as
108    /// [`TextField::in_form`](crate::TextField::in_form) does.
109    #[must_use]
110    pub fn in_form<V: Validate>(
111        label: impl Into<String>,
112        form: Sig<S, Form<V>>,
113        field: Sig<V, T>,
114    ) -> Self {
115        let mut out = Self::new(label, form.field(field));
116        out.frame = out.frame.in_form(form, field);
117        out
118    }
119
120    /// Sets the lowest value the browser accepts.
121    ///
122    /// # Panics
123    ///
124    /// Panics if `min` is above a maximum set before, or is not a number.
125    #[must_use]
126    pub fn min(mut self, min: T) -> Self {
127        assert!(min.is_number(), "a number field's minimum is a number");
128        assert!(
129            self.max.is_none_or(|max| min <= max),
130            "a number field's minimum is not above its maximum"
131        );
132        self.min = Some(min);
133        self
134    }
135
136    /// Sets the highest value the browser accepts.
137    ///
138    /// # Panics
139    ///
140    /// Panics if `max` is below a minimum set before, or is not a number.
141    #[must_use]
142    pub fn max(mut self, max: T) -> Self {
143        assert!(max.is_number(), "a number field's maximum is a number");
144        assert!(
145            self.min.is_none_or(|min| min <= max),
146            "a number field's maximum is not below its minimum"
147        );
148        self.max = Some(max);
149        self
150    }
151
152    /// Sets how far the arrow keys move the value, and which values the
153    /// browser accepts: the minimum plus a whole number of steps.
154    ///
155    /// # Panics
156    ///
157    /// Panics if `step` is not above zero.
158    #[must_use]
159    pub fn step(mut self, step: T) -> Self {
160        assert!(step > T::ZERO, "a number field's step is above zero");
161        self.step = Some(step);
162        self
163    }
164
165    /// Adds a line under the field that `aria-describedby` points at.
166    #[must_use]
167    pub fn description(mut self, text: impl Into<String>) -> Self {
168        self.frame.description = Some(text.into());
169        self
170    }
171
172    /// Shows `message` under the field and marks the field invalid while
173    /// `message` is not empty.
174    #[must_use]
175    pub fn error(mut self, message: impl IntoExpr<S, String>) -> Self {
176        self.frame.error = Some(message.into_expr());
177        self
178    }
179
180    /// Marks the field `required`, which the label shows with an asterisk
181    /// that a screen reader skips.
182    #[must_use]
183    pub fn required(mut self) -> Self {
184        self.frame.required = true;
185        self
186    }
187
188    /// Disables the field while `when` is true.
189    #[must_use]
190    pub fn disabled(mut self, when: impl IntoExpr<S, bool>) -> Self {
191        self.frame.disabled = Some(when.into_expr());
192        self
193    }
194
195    /// The label, the field, the description, and the error, in a `<div>`.
196    #[must_use]
197    pub fn render(self) -> El<S> {
198        let mut control = input()
199            .attr("type", "number")
200            .css(field_style().height(rem(2.25)))
201            .bind(self.value);
202        if self.min.is_some_and(|min| min >= T::ZERO) {
203            control = control.attr("inputmode", T::NON_NEGATIVE_MODE);
204        }
205        if let Some(min) = self.min {
206            control = control.attr("min", min.to_string());
207        }
208        if let Some(max) = self.max {
209            control = control.attr("max", max.to_string());
210        }
211        match (self.step, T::DEFAULT_STEP) {
212            (Some(step), _) => control = control.attr("step", step.to_string()),
213            (None, Some(step)) => control = control.attr("step", step),
214            (None, None) => {}
215        }
216        self.frame.render(&crate::id::of(self.value, ""), control)
217    }
218}
219
220impl<S, T: Numeric> From<NumberField<S, T>> for El<S> {
221    fn from(field: NumberField<S, T>) -> Self {
222        field.render()
223    }
224}
225
226impl<S, T: Numeric> From<NumberField<S, T>> for hypersig::html::Node<S> {
227    fn from(field: NumberField<S, T>) -> Self {
228        field.render().into()
229    }
230}