Skip to main content

freya_components/
link.rs

1use std::borrow::Cow;
2
3use dioxus::prelude::*;
4use dioxus_router::prelude::{
5    navigator,
6    NavigationTarget,
7};
8use freya_core::platform::MouseButton;
9use freya_elements::{
10    self as dioxus_elements,
11    events::MouseEvent,
12};
13use freya_hooks::{
14    use_applied_theme,
15    LinkThemeWith,
16};
17
18use crate::{
19    Tooltip,
20    TooltipContainer,
21};
22
23/// Tooltip configuration for the [`Link()`] component.
24#[derive(Clone, PartialEq)]
25pub enum LinkTooltip {
26    /// No tooltip at all.
27    None,
28    /// Default tooltip.
29    ///
30    /// - For a route, this is the same as [`None`](crate::LinkTooltip::None).
31    /// - For a URL, this is the value of that URL.
32    Default,
33    /// Custom tooltip to always show.
34    Custom(String),
35}
36
37/// Similar to [`Link`](dioxus_router::components::Link()), but you can use it in Freya.
38/// Both internal routes (dioxus-router) and external links are supported. When using internal routes
39/// make sure the Link is descendant of a [`Router`](dioxus_router::components::Router) component.
40///
41/// # Styling
42///
43/// Inherits the [`LinkTheme`](freya_hooks::LinkTheme) theme.
44///
45/// # Example
46///
47/// With Dioxus Router:
48///
49/// ```rust
50/// # use dioxus::prelude::*;
51/// # use dioxus_router::prelude::*;
52/// # use freya_elements as dioxus_elements;
53/// # use freya_components::Link;
54/// # #[derive(Routable, Clone)]
55/// # #[rustfmt::skip]
56/// # enum AppRouter {
57/// #     #[route("/")]
58/// #     Settings,
59/// #     #[route("/..routes")]
60/// #     NotFound
61/// # }
62/// # #[component]
63/// # fn Settings() -> Element { rsx!(rect { })}
64/// # #[component]
65/// # fn NotFound() -> Element { rsx!(rect { })}
66/// # fn link_example_good() -> Element {
67/// rsx! {
68///     Link {
69///         to: AppRouter::Settings,
70///         label { "App Settings" }
71///     }
72/// }
73/// # }
74/// ```
75///
76/// With external routes:
77///
78/// ```rust
79/// # use dioxus::prelude::*;
80/// # use freya_elements as dioxus_elements;
81/// # use freya_components::Link;
82/// # fn link_example_good() -> Element {
83/// rsx! {
84///     Link {
85///         to: "https://crates.io/crates/freya",
86///         label { "Freya crates.io" }
87///     }
88/// }
89/// # }
90/// ```
91#[allow(non_snake_case)]
92#[component]
93pub fn Link(
94    /// Theme override.
95    #[props(optional)]
96    theme: Option<LinkThemeWith>,
97    /// The route or external URL string to navigate to.
98    #[props(into)]
99    to: NavigationTarget,
100    /// Inner children for the Link.
101    children: Element,
102    /// This event will be fired if opening an external link fails.
103    #[props(optional)]
104    onerror: Option<EventHandler<()>>,
105    /// A little text hint to show when hovering over the anchor.
106    ///
107    /// Setting this to [`None`] is the same as [`LinkTooltip::Default`].
108    /// To remove the tooltip, set this to [`LinkTooltip::None`].
109    #[props(optional)]
110    tooltip: Option<LinkTooltip>,
111) -> Element {
112    let theme = use_applied_theme!(&theme, link);
113    let mut is_hovering = use_signal(|| false);
114
115    let url = if let NavigationTarget::External(ref url) = to {
116        Some(url.clone())
117    } else {
118        None
119    };
120
121    let onmouseenter = move |_: MouseEvent| {
122        is_hovering.set(true);
123    };
124
125    let onmouseleave = move |_: MouseEvent| {
126        is_hovering.set(false);
127    };
128
129    let onclick = {
130        to_owned![url, to];
131        move |event: MouseEvent| {
132            if !matches!(event.trigger_button, Some(MouseButton::Left)) {
133                return;
134            }
135
136            // Open the url if there is any
137            // otherwise change the dioxus router route
138            if let Some(url) = &url {
139                let res = open::that(url);
140
141                if let (Err(_), Some(onerror)) = (res, onerror.as_ref()) {
142                    onerror.call(());
143                }
144
145                // TODO(marc2332): Log unhandled errors
146            } else {
147                let router = navigator();
148                router.push(to.clone());
149            }
150        }
151    };
152
153    let color = if *is_hovering.read() {
154        theme.highlight_color
155    } else {
156        Cow::Borrowed("inherit")
157    };
158
159    let tooltip = match tooltip {
160        None | Some(LinkTooltip::Default) => url.clone(),
161        Some(LinkTooltip::None) => None,
162        Some(LinkTooltip::Custom(str)) => Some(str),
163    };
164
165    let link = rsx! {
166        rect {
167            onmouseenter,
168            onmouseleave,
169            onclick,
170            color: "{color}",
171            {children}
172        }
173    };
174
175    if let Some(tooltip) = tooltip {
176        rsx!(
177            TooltipContainer {
178                tooltip: rsx!(
179                    Tooltip {
180                        text: tooltip
181                    }
182                ),
183                {link}
184            }
185        )
186    } else {
187        link
188    }
189}
190
191#[cfg(test)]
192mod test {
193    use dioxus_router::prelude::{
194        Outlet,
195        Routable,
196        Router,
197    };
198    use freya::prelude::*;
199    use freya_testing::prelude::*;
200
201    #[tokio::test]
202    pub async fn link() {
203        #[derive(Routable, Clone)]
204        #[rustfmt::skip]
205        enum Route {
206            #[layout(Layout)]
207            #[route("/")]
208            Home,
209            #[route("/somewhere")]
210            Somewhere,
211            #[route("/..routes")]
212            NotFound
213        }
214
215        #[allow(non_snake_case)]
216        #[component]
217        fn NotFound() -> Element {
218            rsx! {
219                label {
220                    "Not found"
221                }
222            }
223        }
224
225        #[allow(non_snake_case)]
226        #[component]
227        fn Home() -> Element {
228            rsx! {
229                label {
230                    "Home"
231                }
232            }
233        }
234
235        #[allow(non_snake_case)]
236        #[component]
237        fn Somewhere() -> Element {
238            rsx! {
239                label {
240                    "Somewhere"
241                }
242            }
243        }
244
245        #[allow(non_snake_case)]
246        #[component]
247        fn Layout() -> Element {
248            rsx!(
249                Link {
250                    to: Route::Home,
251                    Button {
252                        label { "Home" }
253                    }
254                }
255                Link {
256                    to: Route::Somewhere,
257                    Button {
258                        label { "Somewhere" }
259                    }
260                }
261                Outlet::<Route> {}
262            )
263        }
264
265        fn link_app() -> Element {
266            rsx!(Router::<Route> {})
267        }
268
269        let mut utils = launch_test(link_app);
270
271        // Check route is Home
272        assert_eq!(utils.root().get(2).get(0).text(), Some("Home"));
273
274        // Go to the "Somewhere" route
275        utils.click_cursor((10., 55.)).await;
276
277        // Check route is Somewhere
278        assert_eq!(utils.root().get(2).get(0).text(), Some("Somewhere"));
279
280        // Go to the "Home" route again
281        utils.click_cursor((10., 10.)).await;
282
283        // Check route is Home
284        assert_eq!(utils.root().get(2).get(0).text(), Some("Home"));
285    }
286}