dioxus_router/components/
link.rs

1#![allow(clippy::type_complexity)]
2
3use std::fmt::Debug;
4
5use dioxus_core::{Attribute, Element, EventHandler, VNode};
6use dioxus_core_macro::{rsx, Props};
7use dioxus_html::{
8    self as dioxus_elements, ModifiersInteraction, MountedEvent, MouseEvent, PointerInteraction,
9};
10
11use tracing::error;
12
13use crate::navigation::NavigationTarget;
14use crate::utils::use_router_internal::use_router_internal;
15
16/// The properties for a [`Link`].
17#[derive(Props, Clone, PartialEq)]
18pub struct LinkProps {
19    /// The class attribute for the `a` tag.
20    pub class: Option<String>,
21
22    /// A class to apply to the generate HTML anchor tag if the `target` route is active.
23    pub active_class: Option<String>,
24
25    /// The children to render within the generated HTML anchor tag.
26    pub children: Element,
27
28    /// When [`true`], the `target` route will be opened in a new tab.
29    ///
30    /// This does not change whether the [`Link`] is active or not.
31    #[props(default)]
32    pub new_tab: bool,
33
34    /// The onclick event handler.
35    pub onclick: Option<EventHandler<MouseEvent>>,
36
37    /// The onmounted event handler.
38    /// Fired when the `<a>` element is mounted.
39    pub onmounted: Option<EventHandler<MountedEvent>>,
40
41    #[props(default)]
42    /// Whether the default behavior should be executed if an `onclick` handler is provided.
43    ///
44    /// 1. When `onclick` is [`None`] (default if not specified), `onclick_only` has no effect.
45    /// 2. If `onclick_only` is [`false`] (default if not specified), the provided `onclick` handler
46    ///    will be executed after the links regular functionality.
47    /// 3. If `onclick_only` is [`true`], only the provided `onclick` handler will be executed.
48    pub onclick_only: bool,
49
50    /// The rel attribute for the generated HTML anchor tag.
51    ///
52    /// For external `a`s, this defaults to `noopener noreferrer`.
53    pub rel: Option<String>,
54
55    /// The navigation target. Roughly equivalent to the href attribute of an HTML anchor tag.
56    #[props(into)]
57    pub to: NavigationTarget,
58
59    #[props(extends = GlobalAttributes)]
60    attributes: Vec<Attribute>,
61}
62
63impl Debug for LinkProps {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("LinkProps")
66            .field("active_class", &self.active_class)
67            .field("children", &self.children)
68            .field("attributes", &self.attributes)
69            .field("new_tab", &self.new_tab)
70            .field("onclick", &self.onclick.as_ref().map(|_| "onclick is set"))
71            .field("onclick_only", &self.onclick_only)
72            .field("rel", &self.rel)
73            .finish()
74    }
75}
76
77/// A link to navigate to another route.
78///
79/// Only works as descendant of a [`super::Router`] component, otherwise it will be inactive.
80///
81/// Unlike a regular HTML anchor, a [`Link`] allows the router to handle the navigation and doesn't
82/// cause the browser to load a new page.
83///
84/// However, in the background a [`Link`] still generates an anchor, which you can use for styling
85/// as normal.
86///
87/// # External targets
88/// When the [`Link`]s target is an [`NavigationTarget::External`] target, that is used as the `href` directly. This
89/// means that a [`Link`] can always navigate to an [`NavigationTarget::External`] target, even if the [`dioxus_history::History`] does not support it.
90///
91/// # Panic
92/// - When the [`Link`] is not nested within a [`super::Router`], but
93///   only in debug builds.
94///
95/// # Example
96/// ```rust
97/// # use dioxus::prelude::*;
98///
99/// #[derive(Clone, Routable)]
100/// enum Route {
101///     #[route("/")]
102///     Index {},
103/// }
104///
105/// #[component]
106/// fn App() -> Element {
107///     rsx! {
108///         Router::<Route> {}
109///     }
110/// }
111///
112/// #[component]
113/// fn Index() -> Element {
114///     rsx! {
115///         Link {
116///             active_class: "active",
117///             class: "link_class",
118///             id: "link_id",
119///             new_tab: true,
120///             rel: "link_rel",
121///             to: Route::Index {},
122///
123///             "A fully configured link"
124///         }
125///     }
126/// }
127/// #
128/// # let mut vdom = VirtualDom::new(App);
129/// # vdom.rebuild_in_place();
130/// # assert_eq!(
131/// #     dioxus_ssr::render(&vdom),
132/// #     r#"<a href="/" class="link_class active" rel="link_rel" target="_blank" aria-current="page" id="link_id">A fully configured link</a>"#
133/// # );
134/// ```
135#[doc(alias = "<a>")]
136#[allow(non_snake_case)]
137pub fn Link(props: LinkProps) -> Element {
138    let LinkProps {
139        active_class,
140        children,
141        attributes,
142        new_tab,
143        onclick,
144        onclick_only,
145        rel,
146        to,
147        class,
148        ..
149    } = props;
150
151    // hook up to router
152    let router = match use_router_internal() {
153        Some(r) => r,
154        #[allow(unreachable_code)]
155        None => {
156            let msg = "`Link` must have access to a parent router";
157            error!("{msg}, will be inactive");
158            #[cfg(debug_assertions)]
159            panic!("{}", msg);
160            return VNode::empty();
161        }
162    };
163
164    let current_url = router.full_route_string();
165    let href = match &to {
166        NavigationTarget::Internal(url) => url.clone(),
167        NavigationTarget::External(route) => route.clone(),
168    };
169    // Add the history's prefix to internal hrefs for use in the rsx
170    let full_href = match &to {
171        NavigationTarget::Internal(url) => router.prefix().unwrap_or_default() + url,
172        NavigationTarget::External(route) => route.clone(),
173    };
174
175    let mut class_ = String::new();
176    if let Some(c) = class {
177        class_.push_str(&c);
178    }
179    if let Some(c) = active_class {
180        if href == current_url {
181            if !class_.is_empty() {
182                class_.push(' ');
183            }
184            class_.push_str(&c);
185        }
186    }
187
188    let class = if class_.is_empty() {
189        None
190    } else {
191        Some(class_)
192    };
193
194    let aria_current = (href == current_url).then_some("page");
195
196    let tag_target = new_tab.then_some("_blank");
197
198    let is_external = matches!(to, NavigationTarget::External(_));
199    let is_router_nav = !is_external && !new_tab;
200    let rel = rel.or_else(|| is_external.then_some("noopener noreferrer".to_string()));
201
202    let do_default = onclick.is_none() || !onclick_only;
203
204    let action = move |event: MouseEvent| {
205        // Only handle events without modifiers
206        if !event.modifiers().is_empty() {
207            return;
208        }
209        // Only handle left clicks
210        if event.trigger_button() != Some(dioxus_elements::input_data::MouseButton::Primary) {
211            return;
212        }
213
214        // If we need to open in a new tab, let the browser handle it
215        if new_tab {
216            return;
217        }
218
219        // todo(jon): this is extra hacky for no reason - we should fix prevent default on Links
220        if do_default && is_external {
221            return;
222        }
223
224        event.prevent_default();
225
226        if do_default && is_router_nav {
227            router.push_any(to.clone());
228        }
229
230        if let Some(handler) = onclick {
231            handler.call(event);
232        }
233    };
234
235    let onmounted = move |event| {
236        if let Some(handler) = props.onmounted {
237            handler.call(event);
238        }
239    };
240
241    // In liveview, we need to prevent the default action if the user clicks on the link with modifiers
242    // in javascript. The prevent_default method is not available in the liveview renderer because
243    // event handlers are handled over a websocket.
244    let liveview_prevent_default = {
245        // If the event is a click with the left mouse button and no modifiers, prevent the default action
246        // and navigate to the href with client side routing
247        router.include_prevent_default().then_some(
248            "if (event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) { event.preventDefault() }"
249        )
250    };
251
252    rsx! {
253        a {
254            onclick: action,
255            "onclick": liveview_prevent_default,
256            href: full_href,
257            onmounted: onmounted,
258            class,
259            rel,
260            target: tag_target,
261            aria_current,
262            ..attributes,
263            {children}
264        }
265    }
266}