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}