patternfly_yew/components/
slider.rs

1//! Slider control
2use gloo_events::{EventListener, EventListenerOptions};
3use gloo_utils::document;
4use std::fmt::{Display, Formatter};
5use wasm_bindgen::JsCast;
6use web_sys::HtmlElement;
7use yew::html::IntoPropValue;
8use yew::prelude::*;
9
10#[derive(Clone, PartialEq)]
11pub struct Step {
12    pub value: f64,
13    pub label: Option<String>,
14}
15
16impl From<f64> for Step {
17    fn from(value: f64) -> Self {
18        Self { value, label: None }
19    }
20}
21
22impl IntoPropValue<Step> for f64 {
23    fn into_prop_value(self) -> Step {
24        self.into()
25    }
26}
27
28impl<S> IntoPropValue<Step> for (f64, S)
29where
30    S: Into<String>,
31{
32    fn into_prop_value(self) -> Step {
33        Step {
34            value: self.0,
35            label: Some(self.1.into()),
36        }
37    }
38}
39
40impl<S> From<(f64, S)> for Step
41where
42    S: Into<String>,
43{
44    fn from((value, label): (f64, S)) -> Self {
45        Step {
46            value,
47            label: Some(label.into()),
48        }
49    }
50}
51
52impl Display for Step {
53    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
54        match &self.label {
55            Some(label) => f.write_str(label),
56            None => write!(f, "{}", self.value),
57        }
58    }
59}
60
61/// Properties for [`Slider`]
62#[derive(Clone, PartialEq, Properties)]
63pub struct SliderProperties {
64    /// The minimum value.
65    pub min: Step,
66    /// The maximum value.
67    pub max: Step,
68
69    /// The initial value.
70    #[prop_or_default]
71    pub value: Option<f64>,
72
73    /// Flag to hide the label.
74    #[prop_or_default]
75    pub hide_labels: bool,
76
77    /// The precision of the value label.
78    #[prop_or(2)]
79    pub label_precision: usize,
80
81    #[prop_or_default]
82    pub ticks: Vec<Step>,
83
84    /// An option to suppress reporting the initial value as change.
85    #[prop_or_default]
86    pub suppress_initial_change: bool,
87
88    /// A callback reporting changes.
89    #[prop_or_default]
90    pub onchange: Callback<f64>,
91
92    #[prop_or_default]
93    pub snap_mode: SnapMode,
94}
95
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
97pub enum SnapMode {
98    #[default]
99    None,
100    Nearest,
101}
102
103#[doc(hidden)]
104pub enum SliderMsg {
105    // set the value as original value
106    SetValue(f64),
107    Start(Input, i32),
108    Move(i32),
109    Stop,
110}
111
112#[derive(Clone, Copy, Debug, PartialEq, Eq)]
113pub enum Input {
114    Mouse,
115    Touch,
116}
117
118/// Slider component
119///
120/// > A **slider** provides a quick and effective way for users to set and adjust a numeric value from a defined range of values.
121///
122/// See: <https://www.patternfly.org/components/slider>
123///
124/// ## Properties
125///
126/// Defined by [`SliderProperties`].
127pub struct Slider {
128    // value in percent (0..=1)
129    value: f64,
130
131    mousemove: Option<EventListener>,
132    mouseup: Option<EventListener>,
133    touchmove: Option<EventListener>,
134    touchend: Option<EventListener>,
135    touchcancel: Option<EventListener>,
136
137    refs: Refs,
138    snap_mode: SnapMode,
139    ticks: Vec<f64>,
140}
141
142#[derive(Default)]
143struct Refs {
144    rail: NodeRef,
145}
146
147impl Component for Slider {
148    type Message = SliderMsg;
149    type Properties = SliderProperties;
150
151    fn create(ctx: &Context<Self>) -> Self {
152        let ticks = Self::value_ticks(ctx.props());
153
154        let value = match ctx.props().value {
155            Some(value) => value,
156            None => ctx.props().min.value,
157        };
158
159        if !ctx.props().suppress_initial_change {
160            // initial send a change event
161            ctx.props().onchange.emit(value);
162        }
163
164        let snap_mode = ctx.props().snap_mode;
165
166        Self {
167            value,
168            refs: Default::default(),
169
170            mousemove: None,
171            mouseup: None,
172            touchmove: None,
173            touchend: None,
174            touchcancel: None,
175
176            snap_mode,
177            ticks,
178        }
179    }
180
181    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
182        match msg {
183            SliderMsg::SetValue(value) => {
184                if self.value != value {
185                    self.value = value;
186                    ctx.props().onchange.emit(self.value);
187                } else {
188                    return false;
189                }
190            }
191            SliderMsg::Start(input, x) => {
192                log::debug!("Start: {x}");
193                match input {
194                    Input::Mouse => self.start_mouse(ctx),
195                    Input::Touch => self.start_touch(ctx),
196                }
197            }
198            SliderMsg::Move(x) => {
199                log::debug!("Move: {x}");
200                self.r#move(ctx, x);
201            }
202            SliderMsg::Stop => {
203                log::debug!("Stop");
204                self.mousemove = None;
205                self.mouseup = None;
206                self.touchmove = None;
207                self.touchend = None;
208                self.touchcancel = None;
209            }
210        }
211        true
212    }
213
214    fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
215        let props = ctx.props();
216        if old_props != props {
217            if old_props.value != props.value {
218                if let Some(value) = props.value {
219                    ctx.link().send_message(SliderMsg::SetValue(value));
220                }
221            };
222            true
223        } else {
224            false
225        }
226    }
227
228    fn view(&self, ctx: &Context<Self>) -> Html {
229        let classes = Classes::from("pf-v5-c-slider");
230        let valuestr = format!("{0:.1$}", self.value, ctx.props().label_precision);
231        let valuestr = valuestr.trim_end_matches('0').to_string();
232
233        let onmousedown = ctx.link().callback(|e: MouseEvent| {
234            e.stop_propagation();
235            e.prevent_default();
236            SliderMsg::Start(Input::Mouse, e.client_x())
237        });
238
239        let ontouchstart = ctx.link().batch_callback(|e: TouchEvent| {
240            e.stop_propagation();
241            if let Some(t) = e.touches().get(0) {
242                vec![SliderMsg::Start(Input::Touch, t.client_x())]
243            } else {
244                vec![]
245            }
246        });
247        let percent = Self::calc_percent(self.value, ctx.props()) * 100f64;
248        let min = &ctx.props().min;
249        let max = &ctx.props().max;
250
251        html!(
252            <div class={classes} style={format!("--pf-v5-c-slider--value: {}%", percent)}>
253                <div class="pf-v5-c-slider__main">
254                    <div class="pf-v5-c-slider__rail" ref={self.refs.rail.clone()}>
255                        <div class="pf-v5-c-slider__rail-track"></div>
256                    </div>
257                    if !ctx.props().hide_labels {
258                        <div class="pf-v5-c-slider__steps" aria-hidden="true">
259                            { self.render_step(min, ctx.props()) }
260                            { for ctx.props().ticks.iter()
261                                .filter(|t| t.value>min.value && t.value<max.value)
262                                .map(|t| self.render_step(t,ctx.props()))}
263                            { self.render_step(max, ctx.props()) }
264                        </div>
265                    }
266                    <div class="pf-v5-c-slider__thumb"
267                        {onmousedown}
268                        {ontouchstart}
269                        role="slider"
270                        aria-valuemin={ctx.props().min.value.to_string()}
271                        aria-valuemax={ctx.props().max.value.to_string()}
272                        aria-valuenow={valuestr}
273                        aria-label="Value"
274                        tabindex="0"
275                        >
276                    </div>
277                </div>
278            </div>
279        )
280    }
281}
282
283impl Slider {
284    fn start_mouse(&mut self, ctx: &Context<Self>) {
285        let onmove = ctx.link().callback(SliderMsg::Move);
286        let onstop = ctx.link().callback(|_: ()| SliderMsg::Stop);
287
288        let mousemove = {
289            let onmove = onmove;
290            EventListener::new_with_options(
291                &document(),
292                "mousemove",
293                EventListenerOptions::enable_prevent_default(),
294                move |event| {
295                    if let Some(e) = event.dyn_ref::<MouseEvent>() {
296                        e.stop_propagation();
297                        e.prevent_default();
298                        onmove.emit(e.client_x());
299                    }
300                },
301            )
302        };
303        self.mousemove = Some(mousemove);
304
305        let mouseup = EventListener::new_with_options(
306            &document(),
307            "mouseup",
308            EventListenerOptions::default(),
309            move |_| {
310                onstop.emit(());
311            },
312        );
313        self.mouseup = Some(mouseup);
314    }
315
316    fn start_touch(&mut self, ctx: &Context<Self>) {
317        let onmove = ctx.link().callback(SliderMsg::Move);
318        let onstop = ctx.link().callback(|_: ()| SliderMsg::Stop);
319
320        let touchmove = EventListener::new_with_options(
321            &document(),
322            "touchmove",
323            EventListenerOptions::enable_prevent_default(),
324            move |event| {
325                if let Some(e) = event.dyn_ref::<TouchEvent>() {
326                    e.prevent_default();
327                    e.stop_immediate_propagation();
328                    if let Some(t) = e.touches().get(0) {
329                        onmove.emit(t.client_x());
330                    }
331                }
332            },
333        );
334        self.touchmove = Some(touchmove);
335
336        let touchend = {
337            let onstop = onstop.clone();
338            EventListener::new_with_options(
339                &document(),
340                "touchend",
341                EventListenerOptions::default(),
342                move |_| {
343                    onstop.emit(());
344                },
345            )
346        };
347        self.touchend = Some(touchend);
348
349        let touchcancel = EventListener::new_with_options(
350            &document(),
351            "touchcancel",
352            EventListenerOptions::default(),
353            move |_| {
354                onstop.emit(());
355            },
356        );
357        self.touchcancel = Some(touchcancel);
358    }
359
360    fn r#move(&mut self, ctx: &Context<Self>, x: i32) {
361        if let Some(ele) = self.refs.rail.cast::<HtmlElement>() {
362            let bounding = ele.get_bounding_client_rect();
363
364            let left = bounding.left();
365            let width = bounding.width();
366
367            let value = x as f64 - left;
368
369            let value = if value <= 0f64 {
370                0f64
371            } else if value >= width {
372                1f64
373            } else {
374                value / width
375            };
376
377            let value = Self::calc_value(value, ctx.props());
378            let value = self.snap(value);
379
380            ctx.link().send_message(SliderMsg::SetValue(value))
381        }
382    }
383
384    fn calc_percent(value: f64, props: &SliderProperties) -> f64 {
385        let delta = props.max.value - props.min.value;
386        let p = (value - props.min.value) / delta;
387        p.clamp(0f64, 1f64)
388    }
389
390    fn calc_value(p: f64, props: &SliderProperties) -> f64 {
391        let delta = props.max.value - props.min.value;
392        props.min.value + delta * p
393    }
394
395    fn render_step(&self, step: &Step, props: &SliderProperties) -> Html {
396        let active = step.value <= self.value;
397
398        let mut classes = classes!("pf-v5-c-slider__step");
399        if active {
400            classes.push(classes!("pf-m-active"));
401        }
402        let label = if let Some(label) = &step.label {
403            label.clone()
404        } else {
405            format!("{:.1$}", step.value, props.label_precision)
406        };
407
408        let position = Self::calc_percent(step.value, props) * 100f64;
409        html!(
410            <div class={classes} style={format!("--pf-v5-c-slider__step--Left: {}%", position)}>
411                <div class="pf-v5-c-slider__step-tick"></div>
412                <div class="pf-v5-c-slider__step-label">{ label }</div>
413            </div>
414        )
415    }
416
417    fn snap(&self, value: f64) -> f64 {
418        match &self.snap_mode {
419            SnapMode::None => value,
420            SnapMode::Nearest => snap_nearest(value, &self.ticks),
421        }
422    }
423
424    fn value_ticks(props: &SliderProperties) -> Vec<f64> {
425        let mut ticks = vec![props.min.value, props.max.value];
426        ticks.extend(
427            props
428                .ticks
429                .iter()
430                .map(|t| t.value)
431                .filter(|v| v.is_finite()),
432        );
433        ticks.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
434        ticks
435    }
436}
437
438fn snap_nearest(value: f64, ticks: &[f64]) -> f64 {
439    // assuming we only have a hand-full of ticks, we just scan
440    let mut best = None;
441    for t in ticks {
442        match best {
443            None => best = Some((*t, (t - value).abs())),
444            Some((_, cd)) => {
445                let nd = (t - value).abs();
446                if nd < cd {
447                    best = Some((*t, nd));
448                } else {
449                    // if it's getting bigger, there is no need to continue
450                    // as we have a sorted vec
451                    break;
452                }
453            }
454        }
455    }
456
457    // if we have normal values, we never get None, but let's be sure
458    best.map(|(value, _delta)| value).unwrap_or_default()
459}
460
461#[cfg(test)]
462mod test {
463    use super::*;
464
465    #[test]
466    fn test_snap_nearest() {
467        let ticks = [0f64, 25.0, 50.0, 100.0];
468
469        assert_eq!(snap_nearest(-1.0, &ticks), 0.0);
470
471        assert_eq!(snap_nearest(0.0, &ticks), 0.0);
472        assert_eq!(snap_nearest(25.0, &ticks), 25.0);
473        assert_eq!(snap_nearest(49.0, &ticks), 50.0);
474        assert_eq!(snap_nearest(51.0, &ticks), 50.0);
475        assert_eq!(snap_nearest(75.0, &ticks), 50.0);
476        assert_eq!(snap_nearest(75.1, &ticks), 100.0);
477        assert_eq!(snap_nearest(100.0, &ticks), 100.0);
478
479        assert_eq!(snap_nearest(101.0, &ticks), 100.0);
480    }
481}