freya_components/
switch.rs

1use dioxus::prelude::*;
2use freya_core::platform::CursorIcon;
3use freya_elements::{
4    self as dioxus_elements,
5    events::{
6        KeyboardEvent,
7        MouseEvent,
8    },
9};
10use freya_hooks::{
11    use_animation_with_dependencies,
12    use_applied_theme,
13    use_focus,
14    use_platform,
15    AnimColor,
16    AnimNum,
17    Ease,
18    Function,
19    OnDepsChange,
20    SwitchThemeWith,
21};
22
23/// Properties for the [`Switch`] component.
24#[derive(Props, Clone, PartialEq)]
25pub struct SwitchProps {
26    /// Theme override.
27    pub theme: Option<SwitchThemeWith>,
28    /// Whether the `Switch` is enabled or not.
29    pub enabled: bool,
30    /// Handler for the `ontoggled` event.
31    pub ontoggled: EventHandler<()>,
32}
33
34/// Describes the current status of the Switch.
35#[derive(Debug, Default, PartialEq, Clone, Copy)]
36pub enum SwitchStatus {
37    /// Default state.
38    #[default]
39    Idle,
40    /// Mouse is hovering the switch.
41    Hovering,
42}
43
44/// Display whether a state is `true` or `false`.
45/// Commonly used for enabled/disabled scenarios.
46/// Example: light/dark theme.
47///
48/// # Styling
49///
50/// Inherits the [`SwitchTheme`](freya_hooks::SwitchTheme) theme.
51///
52/// # Example
53///
54/// ```rust
55/// # use freya::prelude::*;
56/// fn app() -> Element {
57///     let mut enabled = use_signal(|| false);
58///
59///     rsx!(Switch {
60///         enabled: enabled(),
61///         ontoggled: move |_| {
62///             enabled.toggle();
63///         }
64///     })
65/// }
66/// # use freya_testing::prelude::*;
67/// # // ENABLED
68/// # use freya_testing::prelude::*;
69/// # launch_doc_with_utils(|| {
70/// #   rsx!(
71/// #       Preview {
72/// #           Switch {
73/// #               enabled: true,
74/// #               ontoggled: move |_| { }
75/// #           }
76/// #       }
77/// #   )
78/// # }, (250., 250.).into(), |mut utils| async move {
79/// #   utils.wait_for_update().await;
80/// #   tokio::time::sleep(std::time::Duration::from_millis(50)).await;
81/// #   utils.wait_for_update().await;
82/// #   utils.save_snapshot("./images/gallery_enabled_switch.png");
83/// # });
84/// #
85/// # // DISABLED
86/// # use freya_testing::prelude::*;
87/// # launch_doc(|| {
88/// #   rsx!(
89/// #       Preview {
90/// #           Switch {
91/// #               enabled: false,
92/// #               ontoggled: move |_| { }
93/// #           }
94/// #       }
95/// #   )
96/// # }, (250., 250.).into(), "./images/gallery_not_enabled_switch.png");
97/// ```
98/// # Preview
99///
100/// | Enabled       | Not Enabled   |
101/// | ------------- | ------------- |
102/// | ![Switch Enabled Demo][gallery_enabled_switch] | ![Switch Not Enabled Demo][gallery_not_enabled_switch] |
103#[cfg_attr(feature = "docs",
104    doc = embed_doc_image::embed_image!(
105        "gallery_not_enabled_switch",
106        "images/gallery_not_enabled_switch.png"
107    )
108)]
109#[cfg_attr(feature = "docs",
110    doc = embed_doc_image::embed_image!("gallery_enabled_switch", "images/gallery_enabled_switch.png")
111)]
112#[allow(non_snake_case)]
113pub fn Switch(props: SwitchProps) -> Element {
114    let theme = use_applied_theme!(&props.theme, switch);
115    let animation = use_animation_with_dependencies(&theme, |conf, theme| {
116        conf.on_deps_change(OnDepsChange::Finish);
117        (
118            AnimNum::new(2., 22.)
119                .time(300)
120                .function(Function::Expo)
121                .ease(Ease::Out),
122            AnimNum::new(14., 18.)
123                .time(300)
124                .function(Function::Expo)
125                .ease(Ease::Out),
126            AnimColor::new(&theme.background, &theme.enabled_background)
127                .time(300)
128                .function(Function::Expo)
129                .ease(Ease::Out),
130            AnimColor::new(&theme.thumb_background, &theme.enabled_thumb_background)
131                .time(300)
132                .function(Function::Expo)
133                .ease(Ease::Out),
134        )
135    });
136    let platform = use_platform();
137    let mut status = use_signal(SwitchStatus::default);
138    let mut focus = use_focus();
139
140    let a11y_id = focus.attribute();
141
142    use_drop(move || {
143        if *status.read() == SwitchStatus::Hovering {
144            platform.set_cursor(CursorIcon::default());
145        }
146    });
147
148    let onmousedown = |e: MouseEvent| {
149        e.stop_propagation();
150    };
151
152    let onmouseleave = move |e: MouseEvent| {
153        e.stop_propagation();
154        *status.write() = SwitchStatus::Idle;
155        platform.set_cursor(CursorIcon::default());
156    };
157
158    let onmouseenter = move |e: MouseEvent| {
159        e.stop_propagation();
160        *status.write() = SwitchStatus::Hovering;
161        platform.set_cursor(CursorIcon::Pointer);
162    };
163
164    let onclick = move |e: MouseEvent| {
165        e.stop_propagation();
166        focus.request_focus();
167        props.ontoggled.call(());
168    };
169
170    let onkeydown = move |e: KeyboardEvent| {
171        if focus.validate_keydown(&e) {
172            props.ontoggled.call(());
173        }
174    };
175
176    let (offset_x, size, background, circle) = &*animation.get().read_unchecked();
177    let offset_x = offset_x.read();
178    let size = size.read();
179    let background = background.read();
180    let circle = circle.read();
181
182    let border = if focus.is_focused_with_keyboard() {
183        if props.enabled {
184            format!("2 inner {}", theme.enabled_focus_border_fill)
185        } else {
186            format!("2 inner {}", theme.focus_border_fill)
187        }
188    } else {
189        "none".to_string()
190    };
191
192    use_memo(use_reactive(&props.enabled, move |enabled| {
193        if enabled {
194            animation.start();
195        } else if animation.peek_has_run_yet() {
196            animation.reverse();
197        }
198    }));
199
200    let a11y_toggled = if props.enabled { "true" } else { "false" };
201
202    rsx!(
203        rect {
204            margin: "{theme.margin}",
205            width: "48",
206            height: "25",
207            padding: "4",
208            corner_radius: "50",
209            background: "{background}",
210            border: "{border}",
211            onmousedown,
212            onmouseenter,
213            onmouseleave,
214            onkeydown,
215            onclick,
216            a11y_role: "switch",
217            a11y_id,
218            a11y_toggled,
219            offset_x: "{offset_x}",
220            main_align: "center",
221            rect {
222                background: "{circle}",
223                width: "{size}",
224                height: "{size}",
225                corner_radius: "50",
226            }
227        }
228    )
229}
230
231#[cfg(test)]
232mod test {
233    use dioxus::prelude::use_signal;
234    use freya::prelude::*;
235    use freya_testing::prelude::*;
236
237    #[tokio::test]
238    pub async fn switch() {
239        fn switch_app() -> Element {
240            let mut enabled = use_signal(|| false);
241
242            rsx!(
243                Switch {
244                    enabled: *enabled.read(),
245                    ontoggled: move |_| {
246                        enabled.toggle();
247                    }
248                }
249                label {
250                    "{enabled}"
251                }
252            )
253        }
254
255        let mut utils = launch_test(switch_app);
256        let root = utils.root();
257        let label = root.get(1);
258        utils.wait_for_update().await;
259
260        // Default is false
261        assert_eq!(label.get(0).text(), Some("false"));
262
263        utils.click_cursor((15., 15.)).await;
264
265        // Check if after clicking it is now enabled
266        assert_eq!(label.get(0).text(), Some("true"));
267
268        utils.click_cursor((15., 15.)).await;
269
270        // Check if after clicking again it is now disabled
271        assert_eq!(label.get(0).text(), Some("false"));
272    }
273}