Skip to main content

hypersig_ui/
slider.rs

1//! Sliders: a labeled native range input drawn as a track and a thumb.
2
3use hypersig::html::{El, div, input, label, span};
4use hypersig::style::pattern::stack;
5use hypersig::style::value::{
6    AlignItems, BorderRadius, BorderStyle, Cursor, Display, JustifyContent, Opacity, PointerEvents,
7    Position, pct, px, rem,
8};
9use hypersig::style::{Condition, PseudoClass, Style};
10use hypersig::{Expr, IntoExpr, Sig};
11
12use crate::field::label_style;
13use crate::look;
14use crate::token;
15
16/// A slider labeled `label` that sets the `i32` signal `value`.
17///
18/// It is a native `<input type="range">`, so the browser gives it
19/// `role="slider"`, the arrow keys, Page Up, Page Down, Home, and End, and
20/// dragging with a pointer or a finger. The input is transparent and lies
21/// over a track and a thumb drawn from the value, so the slider looks the
22/// same in every browser. The value is shown beside the label, hidden from
23/// assistive technology, which reads it from the input.
24///
25/// The thumb is drawn 1rem wide. The browser's own transparent thumb, which
26/// the pointer actually drags, has the width the browser gives it, so the
27/// drawn thumb can sit a few pixels off the pointer near either end.
28///
29/// ```
30/// use hypersig::Signals;
31/// use hypersig_ui::Slider;
32/// use serde::{Deserialize, Serialize};
33///
34/// #[derive(Signals, Serialize, Deserialize)]
35/// struct Player {
36///     volume: i32,
37/// }
38///
39/// let volume = Slider::new("Volume", Player::volume()).range(0, 100).step(5);
40/// let html = volume.render().render();
41/// assert!(html.contains(r#"type="range""#));
42/// assert!(html.contains(r#"max="100""#));
43/// ```
44#[derive(Debug)]
45pub struct Slider<S> {
46    label: String,
47    value: Sig<S, i32>,
48    min: i32,
49    max: i32,
50    step: i32,
51    value_text: Option<Expr<S, String>>,
52    disabled: Option<Expr<S, bool>>,
53}
54
55impl<S> Slider<S> {
56    /// A slider from 0 to 100 in steps of 1, labeled `label`, bound to `value`.
57    #[must_use]
58    pub fn new(label: impl Into<String>, value: Sig<S, i32>) -> Self {
59        Self {
60            label: label.into(),
61            value,
62            min: 0,
63            max: 100,
64            step: 1,
65            value_text: None,
66            disabled: None,
67        }
68    }
69
70    /// Sets the lowest and the highest value.
71    ///
72    /// # Panics
73    ///
74    /// Panics if `min` is not below `max`.
75    #[must_use]
76    pub fn range(mut self, min: i32, max: i32) -> Self {
77        assert!(min < max, "a slider's minimum is below its maximum");
78        self.min = min;
79        self.max = max;
80        self
81    }
82
83    /// Sets how far one arrow key press moves the value.
84    ///
85    /// # Panics
86    ///
87    /// Panics if `step` is not positive.
88    #[must_use]
89    pub fn step(mut self, step: i32) -> Self {
90        assert!(step > 0, "a slider's step is positive");
91        self.step = step;
92        self
93    }
94
95    /// Shows `text` beside the label and gives it to assistive technology as
96    /// `aria-valuetext`, for a value that needs a unit, such as "40 %".
97    #[must_use]
98    pub fn value_text(mut self, text: impl IntoExpr<S, String>) -> Self {
99        self.value_text = Some(text.into_expr());
100        self
101    }
102
103    /// Disables the slider while `when` is true, and dims it.
104    #[must_use]
105    pub fn disabled(mut self, when: impl IntoExpr<S, bool>) -> Self {
106        self.disabled = Some(when.into_expr());
107        self
108    }
109
110    /// The label, the shown value, and the slider, in a `<div>`.
111    #[must_use]
112    pub fn render(self) -> El<S> {
113        let id = crate::id::of(self.value, "slider");
114        let span_of_range = f64::from(self.max) - f64::from(self.min);
115        // The share of the track left of the thumb, from 0 to 1.
116        let share = ((self.value.to_f64() - f64::from(self.min)) / span_of_range)
117            .max(0.0)
118            .min(1.0);
119        let shown = self
120            .value_text
121            .clone()
122            .unwrap_or_else(|| self.value.to_text());
123
124        let mut control = input()
125            .attr("type", "range")
126            .id(id.clone())
127            .attr("min", self.min.to_string())
128            .attr("max", self.max.to_string())
129            .attr("step", self.step.to_string())
130            .bind(self.value)
131            .css(input_style());
132        if let Some(text) = self.value_text {
133            control = control.bind_attr("aria-valuetext", text);
134        }
135        let mut out = div().css(stack(rem(0.75)));
136        if let Some(when) = self.disabled {
137            control = control.disabled(when.clone());
138            // The input is transparent, so the dimming that marks a disabled
139            // control goes on the drawn track and thumb around it.
140            out = out.css_when(Style::new().opacity(Opacity::Number(0.5)), when);
141        }
142
143        out.child(
144            div()
145                .css(
146                    Style::new()
147                        .display(Display::Flex)
148                        .align_items(AlignItems::Center)
149                        .justify_content(JustifyContent::SpaceBetween),
150                )
151                .child(label().attr("for", id).css(label_style()).text(self.label))
152                .child(
153                    span()
154                        .attr("aria-hidden", "true")
155                        .css(look::small_text().color(token::muted_foreground().var()))
156                        .bind_text(shown),
157                ),
158        )
159        .child(
160            div()
161                .css(
162                    Style::new()
163                        .position(Position::Relative)
164                        .height(rem(1))
165                        .width(pct(100)),
166                )
167                .child(control)
168                .child(
169                    div()
170                        .css(track_style())
171                        .child(div().css(fill_style()).bind_style(
172                            hypersig::style::Property::Width,
173                            (share.clone() * 100.0).to_fixed(3).concat("%"),
174                        )),
175                )
176                .child(
177                    div()
178                        .css(thumb_style())
179                        // The thumb's left edge travels the track minus
180                        // its own width, as the browser's thumb does.
181                        .bind_style(
182                            hypersig::style::Property::Left,
183                            Expr::lit(&String::from("calc((100% - 1rem) * "))
184                                .concat(share.to_fixed(4))
185                                .concat(")"),
186                        ),
187                ),
188        )
189    }
190}
191
192/// The transparent native input over the drawn slider, which takes the
193/// pointer and the keyboard.
194fn input_style() -> Style {
195    Style::new()
196        .position(Position::Absolute)
197        .inset(px(0))
198        .width(pct(100))
199        .height(pct(100))
200        .margin(px(0))
201        .opacity(Opacity::Number(0.0))
202        .cursor(Cursor::Pointer)
203        .on(
204            PseudoClass::Disabled,
205            Style::new().cursor(Cursor::NotAllowed),
206        )
207        .peer()
208}
209
210fn track_style() -> Style {
211    Style::new()
212        .position(Position::Absolute)
213        .left(px(0))
214        .right(px(0))
215        .top(rem(0.3125))
216        .height(rem(0.375))
217        .border_radius(BorderRadius::from(px(999)))
218        .background_color(token::muted().var())
219        .pointer_events(PointerEvents::None)
220}
221
222fn fill_style() -> Style {
223    Style::new()
224        .height(pct(100))
225        .border_radius(BorderRadius::from(px(999)))
226        .background_color(token::primary().var())
227}
228
229fn thumb_style() -> Style {
230    Style::new()
231        .position(Position::Absolute)
232        .top(px(0))
233        .width(rem(1))
234        .height(rem(1))
235        .border_width(px(1))
236        .border_style(BorderStyle::Solid)
237        .border_color(token::primary().var())
238        .border_radius(BorderRadius::from(px(999)))
239        .background_color(token::background().var())
240        .box_shadow(hypersig::style::value::raw("0 1px 2px 0 rgb(0 0 0 / 0.05)"))
241        .pointer_events(PointerEvents::None)
242        .merge(look::peer_focus_ring())
243        .on(
244            Condition::Peer(PseudoClass::Disabled),
245            Style::new().opacity(Opacity::Number(0.5)),
246        )
247}
248
249impl<S> From<Slider<S>> for El<S> {
250    fn from(slider: Slider<S>) -> Self {
251        slider.render()
252    }
253}
254
255impl<S> From<Slider<S>> for hypersig::html::Node<S> {
256    fn from(slider: Slider<S>) -> Self {
257        slider.render().into()
258    }
259}