Skip to main content

topcoat_runtime/
link.rs

1// Show router links as plain text when the router feature is disabled.
2#![cfg_attr(not(feature = "router"), allow(rustdoc::broken_intra_doc_links))]
3
4use topcoat_core::{
5    context::{Cx, try_context},
6    error::Result,
7};
8use topcoat_view::{AttributeValueViewParts, Attributes, Child, View};
9use topcoat_view_macro::{component, view};
10
11/// Enables runtime navigation on an anchor and sets its prefetch mode.
12const LINK_ATTRIBUTE: &str = "data-topcoat-link";
13
14/// Controls when the browser loads a linked page before the user follows it.
15///
16/// Loading a page early, or prefetching, can make navigation faster. The
17/// runtime may skip it, for example if the browser is set to save data.
18/// Prefetching renders the page on the server even if the user never opens
19/// it, so rendering a page must be safe in that case.
20///
21/// Set the `prefetch` argument on [`link`] to choose when to load that page.
22/// Otherwise, the link uses the default returned by [`prefetch_mode`].
23#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum PrefetchMode {
25    /// Loads the page only after the user follows the link.
26    Never,
27    /// Loads the page after the user hovers over the link briefly or as
28    /// soon as the link gets focus. This is the default.
29    #[default]
30    Intent,
31    /// Loads the page when the link comes into view.
32    Viewport,
33}
34
35impl PrefetchMode {
36    /// Returns the mode's name for use in the HTML attribute.
37    const fn as_str(self) -> &'static str {
38        match self {
39            Self::Never => "never",
40            Self::Intent => "intent",
41            Self::Viewport => "viewport",
42        }
43    }
44}
45
46/// Reads the default prefetch mode for links rendered with this context.
47///
48/// Returns the context's `PrefetchMode`, falling back to
49/// [`PrefetchMode::Intent`] when no value is set.
50///
51/// Choose the app's default by adding a `PrefetchMode` to the router with
52/// [`RouterBuilder::with`](topcoat_router::RouterBuilder::with). Use
53/// [`Cx::with`] to override it for links that receive a child context.
54#[must_use]
55pub fn prefetch_mode(cx: &Cx) -> PrefetchMode {
56    try_context::<PrefetchMode>(cx).copied().unwrap_or_default()
57}
58
59/// Creates attributes that let an `<a>` element use runtime navigation.
60///
61/// Spread the returned attributes into your own `<a>` element to give it
62/// the same behavior as [`link`]. They set its `href` and enable runtime
63/// navigation with the chosen `prefetch` mode. Use an
64/// [`href!`](https://docs.rs/topcoat/latest/topcoat/router/macro.href.html)
65/// value for a page in your app. Call [`prefetch_mode`] to get the default
66/// mode for the current context.
67///
68/// Without JavaScript, the element still works as a regular link. The
69/// browser also handles clicks with modifier keys, links to other windows
70/// or frames, downloads, and links to other sites as usual.
71#[must_use]
72pub fn link_attrs(
73    cx: &Cx,
74    href: impl AttributeValueViewParts,
75    prefetch: PrefetchMode,
76) -> Attributes {
77    let mut attrs = Attributes::with_capacity(2);
78    attrs.insert(cx, "href", href);
79    attrs.insert(cx, LINK_ATTRIBUTE, prefetch.as_str());
80    attrs
81}
82
83#[doc = include_str!("../docs/link.md")]
84#[component]
85pub async fn link<H>(
86    cx: &Cx,
87    /// The URL to open. Use `href!` for pages in your app.
88    href: H,
89    /// When to load the page before the user follows the link. Uses
90    /// [`prefetch_mode`] if omitted.
91    #[into]
92    #[default]
93    prefetch: Option<PrefetchMode>,
94    /// Additional HTML attributes to put on the link.
95    #[default]
96    mut attrs: Attributes,
97    /// The text or other content inside the link.
98    #[default]
99    child: Child<'_>,
100) -> Result<impl View>
101where
102    H: AttributeValueViewParts + Send,
103{
104    let prefetch = prefetch.unwrap_or_else(|| prefetch_mode(cx));
105    attrs.extend(link_attrs(cx, href, prefetch));
106    Ok(view! { <a (attrs)>(child)</a> })
107}
108
109#[cfg(test)]
110mod tests {
111    use std::{
112        pin::pin,
113        task::{Context, Poll, Waker},
114    };
115
116    use topcoat::view::{ViewExt, attributes, view};
117    use topcoat_core::context::Cx;
118
119    use super::*;
120
121    /// Runs a future to completion by polling it repeatedly without a pause.
122    fn block_on<F: Future>(future: F) -> F::Output {
123        let mut future = pin!(future);
124        let mut cx = Context::from_waker(Waker::noop());
125        loop {
126            if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
127                return output;
128            }
129        }
130    }
131
132    fn render(view: impl View) -> String {
133        block_on(view.single()).unwrap().render(&Cx::new())
134    }
135
136    #[test]
137    fn link_attrs_mark_the_anchor_with_its_destination_and_mode() {
138        let cx = &Cx::new();
139        let attrs = link_attrs(cx, "/products", PrefetchMode::Viewport);
140        let html = render(view! { <a (attrs)>"Products"</a> });
141
142        assert!(html.contains(r#"href="/products""#), "{html}");
143        assert!(html.contains(r#"data-topcoat-link="viewport""#), "{html}");
144    }
145
146    #[test]
147    fn every_mode_renders_differently() {
148        let cx = &Cx::new();
149        let mut seen = Vec::new();
150        for prefetch in [
151            PrefetchMode::Never,
152            PrefetchMode::Intent,
153            PrefetchMode::Viewport,
154        ] {
155            let attrs = link_attrs(cx, "/", prefetch);
156            let html = render(view! { <a (attrs)></a> });
157            assert!(!seen.contains(&html), "{html}");
158            seen.push(html);
159        }
160    }
161
162    #[test]
163    fn link_renders_an_anchor_with_forwarded_attributes() {
164        let cx = &Cx::new();
165        let attrs = attributes! { class="nav" aria-current="page" };
166        let html = render(view! { link(href: "/account", attrs: attrs, "Account") });
167
168        assert!(html.starts_with("<a "), "{html}");
169        assert!(html.contains(r#"href="/account""#), "{html}");
170        assert!(html.contains(r#"class="nav""#), "{html}");
171        assert!(html.contains(r#"aria-current="page""#), "{html}");
172        assert!(html.contains(">Account</a>"), "{html}");
173        // The default mode loads the page on hover or focus.
174        assert!(html.contains(r#"data-topcoat-link="intent""#), "{html}");
175    }
176
177    #[test]
178    fn explicit_props_take_precedence_over_forwarded_attributes() {
179        let cx = &Cx::new();
180        let attrs = attributes! { href="/elsewhere" data-topcoat-link="viewport" };
181        let html = render(view! {
182            link(href: "/account", prefetch: PrefetchMode::Never, attrs: attrs)
183        });
184
185        assert!(html.contains(r#"href="/account""#), "{html}");
186        assert!(!html.contains("/elsewhere"), "{html}");
187        assert!(!html.contains("viewport"), "{html}");
188    }
189
190    #[test]
191    fn prefetch_mode_uses_the_innermost_value() {
192        let outer = Cx::new().with(PrefetchMode::Viewport);
193        assert_eq!(prefetch_mode(&outer), PrefetchMode::Viewport);
194
195        let scoped = outer.with(PrefetchMode::Never);
196        assert_eq!(prefetch_mode(&scoped), PrefetchMode::Never);
197    }
198
199    #[test]
200    fn link_uses_the_context_mode_unless_given_one() {
201        let cx = &Cx::new().with(PrefetchMode::Viewport);
202        let html = render(view! { link(href: "/", "Home") });
203        assert!(html.contains(r#"data-topcoat-link="viewport""#), "{html}");
204
205        let explicit = render(view! { link(href: "/", prefetch: PrefetchMode::Never, "Home") });
206        assert!(
207            explicit.contains(r#"data-topcoat-link="never""#),
208            "{explicit}"
209        );
210    }
211}