freya_components/
slider.rs

1use dioxus::prelude::*;
2use freya_core::platform::CursorIcon;
3use freya_elements::{
4    self as dioxus_elements,
5    events::{
6        keyboard::Key,
7        KeyboardEvent,
8        MouseEvent,
9        WheelEvent,
10    },
11};
12use freya_hooks::{
13    use_applied_theme,
14    use_focus,
15    use_node,
16    use_platform,
17    SliderThemeWith,
18};
19
20/// Properties for the [`Slider`] component.
21#[derive(Props, Clone, PartialEq)]
22pub struct SliderProps {
23    /// Theme override.
24    pub theme: Option<SliderThemeWith>,
25    /// Handler for the `onmoved` event.
26    pub onmoved: EventHandler<f64>,
27    /// Size of the Slider.
28    #[props(into, default = "100%".to_string())]
29    pub size: String,
30    /// Height of the Slider.
31    pub value: f64,
32    #[props(default = "horizontal".to_string())]
33    pub direction: String,
34}
35
36#[inline]
37fn ensure_correct_slider_range(value: f64) -> f64 {
38    if value < 0.0 {
39        #[cfg(debug_assertions)]
40        tracing::info!("Slider value is less than 0.0, setting to 0.0");
41        0.0
42    } else if value > 100.0 {
43        #[cfg(debug_assertions)]
44        tracing::info!("Slider value is greater than 100.0, setting to 100.0");
45        100.0
46    } else {
47        value
48    }
49}
50
51/// Describes the current status of the Slider.
52#[derive(Debug, Default, PartialEq, Clone, Copy)]
53pub enum SliderStatus {
54    /// Default state.
55    #[default]
56    Idle,
57    /// Mouse is hovering the slider.
58    Hovering,
59}
60
61/// Controlled `Slider` component.
62///
63/// You must pass a percentage from 0.0 to 100.0 and listen for value changes with `onmoved` and then decide if this changes are applicable,
64/// and if so, apply them.
65///
66/// # Styling
67/// Inherits a [`SliderTheme`](freya_hooks::SliderTheme) theme.
68///
69/// # Example
70/// ```rust
71/// # use freya::prelude::*;
72/// fn app() -> Element {
73///     let mut percentage = use_signal(|| 20.0);
74///
75///     rsx!(
76///         label {
77///             "Value: {percentage}"
78///         }
79///         Slider {
80///             size: "50%",
81///             value: *percentage.read(),
82///             onmoved: move |p| {
83///                 percentage.set(p);
84///             }
85///         }
86///     )
87/// }
88///
89/// # use freya_testing::prelude::*;
90/// # launch_doc(|| {
91/// #   rsx!(
92/// #       Preview {
93/// #           Slider {
94/// #               size: "50%",
95/// #               value: 50.0,
96/// #               onmoved: move |p| { }
97/// #           }
98/// #       }
99/// #   )
100/// # }, (250., 250.).into(), "./images/gallery_slider.png");
101/// ```
102/// # Preview
103/// ![Slider Preview][slider]
104#[cfg_attr(feature = "docs",
105    doc = embed_doc_image::embed_image!("slider", "images/gallery_slider.png")
106)]
107#[allow(non_snake_case)]
108pub fn Slider(
109    SliderProps {
110        value,
111        onmoved,
112        theme,
113        size,
114        direction,
115    }: SliderProps,
116) -> Element {
117    let theme = use_applied_theme!(&theme, slider);
118    let mut focus = use_focus();
119    let mut status = use_signal(SliderStatus::default);
120    let mut clicking = use_signal(|| false);
121    let platform = use_platform();
122    let (node_reference, node_size) = use_node();
123
124    let direction_is_vertical = direction == "vertical";
125    let value = ensure_correct_slider_range(value);
126    let a11y_id = focus.attribute();
127
128    use_drop(move || {
129        if *status.peek() == SliderStatus::Hovering {
130            platform.set_cursor(CursorIcon::default());
131        }
132    });
133
134    let onkeydown = move |e: KeyboardEvent| match e.key {
135        Key::ArrowLeft if !direction_is_vertical => {
136            e.stop_propagation();
137            let percentage = (value - 4.).clamp(0.0, 100.0);
138            onmoved.call(percentage);
139        }
140        Key::ArrowRight if !direction_is_vertical => {
141            e.stop_propagation();
142            let percentage = (value + 4.).clamp(0.0, 100.0);
143            onmoved.call(percentage);
144        }
145        Key::ArrowUp if direction_is_vertical => {
146            e.stop_propagation();
147            let percentage = (value + 4.).clamp(0.0, 100.0);
148            onmoved.call(percentage);
149        }
150        Key::ArrowDown if direction_is_vertical => {
151            e.stop_propagation();
152            let percentage = (value - 4.).clamp(0.0, 100.0);
153            onmoved.call(percentage);
154        }
155        _ => {}
156    };
157
158    let onmouseleave = move |e: MouseEvent| {
159        e.stop_propagation();
160        *status.write() = SliderStatus::Idle;
161        platform.set_cursor(CursorIcon::default());
162    };
163
164    let onmouseenter = move |e: MouseEvent| {
165        e.stop_propagation();
166        *status.write() = SliderStatus::Hovering;
167        platform.set_cursor(CursorIcon::Pointer);
168    };
169
170    let onmousemove = {
171        to_owned![onmoved];
172        move |e: MouseEvent| {
173            e.stop_propagation();
174            if *clicking.peek() {
175                let coordinates = e.get_element_coordinates();
176                let percentage = if direction_is_vertical {
177                    let y = coordinates.y - node_size.area.min_y() as f64 - 6.0;
178                    100. - (y / (node_size.area.height() as f64 - 15.0) * 100.0)
179                } else {
180                    let x = coordinates.x - node_size.area.min_x() as f64 - 6.0;
181                    x / (node_size.area.width() as f64 - 15.0) * 100.0
182                };
183                let percentage = percentage.clamp(0.0, 100.0);
184
185                onmoved.call(percentage);
186            }
187        }
188    };
189
190    let onmousedown = {
191        to_owned![onmoved];
192        move |e: MouseEvent| {
193            e.stop_propagation();
194            focus.request_focus();
195            clicking.set(true);
196            let coordinates = e.get_element_coordinates();
197            let percentage = if direction_is_vertical {
198                let y = coordinates.y - 6.0;
199                100. - (y / (node_size.area.height() as f64 - 15.0) * 100.0)
200            } else {
201                let x = coordinates.x - 6.0;
202                x / (node_size.area.width() as f64 - 15.0) * 100.0
203            };
204            let percentage = percentage.clamp(0.0, 100.0);
205
206            onmoved.call(percentage);
207        }
208    };
209
210    let onclick = move |_: MouseEvent| {
211        clicking.set(false);
212    };
213
214    let onwheel = move |e: WheelEvent| {
215        e.stop_propagation();
216        let wheel_y = e.get_delta_y().clamp(-1.0, 1.0);
217        let percentage = value + (wheel_y * 2.0);
218        let percentage = percentage.clamp(0.0, 100.0);
219
220        onmoved.call(percentage);
221    };
222
223    let border = if focus.is_focused_with_keyboard() {
224        format!("2 inner {}", theme.border_fill)
225    } else {
226        "none".to_string()
227    };
228
229    let (
230        width,
231        height,
232        container_width,
233        container_height,
234        inner_width,
235        inner_height,
236        main_align,
237        offset_x,
238        offset_y,
239    ) = if direction_is_vertical {
240        let inner_height = (node_size.area.height() - 15.0) * (value / 100.0) as f32;
241        (
242            "20",
243            size.as_str(),
244            "6",
245            "100%",
246            "100%".to_string(),
247            inner_height.to_string(),
248            "end",
249            -6,
250            3,
251        )
252    } else {
253        let inner_width = (node_size.area.width() - 15.0) * (value / 100.0) as f32;
254        (
255            size.as_str(),
256            "20",
257            "100%",
258            "6",
259            inner_width.to_string(),
260            "100%".to_string(),
261            "start",
262            -3,
263            -6,
264        )
265    };
266
267    let inner_fill = rsx!(rect {
268        background: "{theme.thumb_inner_background}",
269        width: "{inner_width}",
270        height: "{inner_height}",
271        corner_radius: "50"
272    });
273
274    let thumb = rsx!(
275        rect {
276            width: "fill",
277            offset_x: "{offset_x}",
278            offset_y: "{offset_y}",
279            rect {
280                background: "{theme.thumb_background}",
281                width: "18",
282                height: "18",
283                corner_radius: "50",
284                padding: "4",
285                rect {
286                    height: "100%",
287                    width: "100%",
288                    background: "{theme.thumb_inner_background}",
289                    corner_radius: "50"
290                }
291            }
292        }
293    );
294
295    rsx!(
296        rect {
297            reference: node_reference,
298            width: "{width}",
299            height: "{height}",
300            onmousedown,
301            onglobalclick: onclick,
302            a11y_id,
303            onmouseenter,
304            onglobalmousemove: onmousemove,
305            onmouseleave,
306            onwheel: onwheel,
307            onkeydown,
308            main_align: "center",
309            cross_align: "center",
310            border: "{border}",
311            corner_radius: "8",
312            rect {
313                background: "{theme.background}",
314                width: "{container_width}",
315                height: "{container_height}",
316                main_align: "{main_align}",
317                direction: "{direction}",
318                corner_radius: "50",
319                if direction_is_vertical {
320                    {thumb}
321                    {inner_fill}
322                } else {
323                    {inner_fill}
324                    {thumb}
325                }
326            }
327        }
328    )
329}
330
331#[cfg(test)]
332mod test {
333    use dioxus::prelude::use_signal;
334    use freya::prelude::*;
335    use freya_testing::prelude::*;
336
337    #[tokio::test]
338    pub async fn slider() {
339        fn slider_app() -> Element {
340            let mut value = use_signal(|| 50.);
341
342            rsx!(
343                Slider {
344                    value: *value.read(),
345                    onmoved: move |p| {
346                        value.set(p);
347                    }
348                }
349                label {
350                    "{value}"
351                }
352            )
353        }
354
355        let mut utils = launch_test(slider_app);
356        let root = utils.root();
357        let label = root.get(1);
358        utils.wait_for_update().await;
359
360        assert_eq!(label.get(0).text(), Some("50"));
361
362        utils.push_event(TestEvent::Mouse {
363            name: EventName::MouseMove,
364            cursor: (250.0, 7.0).into(),
365            button: Some(MouseButton::Left),
366        });
367        utils.push_event(TestEvent::Mouse {
368            name: EventName::MouseDown,
369            cursor: (250.0, 7.0).into(),
370            button: Some(MouseButton::Left),
371        });
372        utils.push_event(TestEvent::Mouse {
373            name: EventName::MouseMove,
374            cursor: (500.0, 7.0).into(),
375            button: Some(MouseButton::Left),
376        });
377        utils.wait_for_update().await;
378
379        assert_eq!(label.get(0).text(), Some("100"));
380    }
381}