1#![cfg_attr(not(feature = "router"), allow(rustdoc::broken_intra_doc_links))]
3
4use topcoat_core::{
5 context::{Cx, try_app_context, try_request_context},
6 error::Result,
7};
8use topcoat_view::{AttributeValueViewParts, Attributes, Child, View};
9use topcoat_view_macro::{component, view};
10
11const LINK_ATTRIBUTE: &str = "data-topcoat-link";
13
14#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum PrefetchMode {
25 Never,
27 #[default]
30 Intent,
31 Viewport,
33}
34
35impl PrefetchMode {
36 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#[must_use]
59pub fn prefetch_mode(cx: &Cx) -> PrefetchMode {
60 try_request_context::<PrefetchMode>(cx)
61 .or_else(|| try_app_context::<PrefetchMode>(cx))
62 .copied()
63 .unwrap_or_default()
64}
65
66#[must_use]
79pub fn link_attrs(
80 cx: &Cx,
81 href: impl AttributeValueViewParts,
82 prefetch: PrefetchMode,
83) -> Attributes {
84 let mut attrs = Attributes::with_capacity(2);
85 attrs.insert(cx, "href", href);
86 attrs.insert(cx, LINK_ATTRIBUTE, prefetch.as_str());
87 attrs
88}
89
90#[doc = include_str!("../docs/link.md")]
91#[component]
92pub async fn link<H>(
93 cx: &Cx,
94 href: H,
96 #[into]
99 #[default]
100 prefetch: Option<PrefetchMode>,
101 #[default]
103 mut attrs: Attributes,
104 #[default]
106 child: Child<'_>,
107) -> Result<impl View>
108where
109 H: AttributeValueViewParts + Send,
110{
111 let prefetch = prefetch.unwrap_or_else(|| prefetch_mode(cx));
112 attrs.extend(link_attrs(cx, href, prefetch));
113 Ok(view! { <a (attrs)>(child)</a> })
114}
115
116#[cfg(test)]
117mod tests {
118 use std::{
119 pin::pin,
120 task::{Context, Poll, Waker},
121 };
122
123 use topcoat::view::{ViewExt, attributes, view};
124 use topcoat_core::context::CxTestBuilder;
125
126 use super::*;
127
128 fn block_on<F: Future>(future: F) -> F::Output {
130 let mut future = pin!(future);
131 let mut cx = Context::from_waker(Waker::noop());
132 loop {
133 if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
134 return output;
135 }
136 }
137 }
138
139 fn render(view: impl View) -> String {
140 block_on(view.single()).unwrap().render(&Cx::default())
141 }
142
143 #[test]
144 fn link_attrs_mark_the_anchor_with_its_destination_and_mode() {
145 let cx = &Cx::default();
146 let attrs = link_attrs(cx, "/products", PrefetchMode::Viewport);
147 let html = render(view! { cx => <a (attrs)>"Products"</a> });
148
149 assert!(html.contains(r#"href="/products""#), "{html}");
150 assert!(html.contains(r#"data-topcoat-link="viewport""#), "{html}");
151 }
152
153 #[test]
154 fn every_mode_renders_differently() {
155 let cx = &Cx::default();
156 let mut seen = Vec::new();
157 for prefetch in [
158 PrefetchMode::Never,
159 PrefetchMode::Intent,
160 PrefetchMode::Viewport,
161 ] {
162 let attrs = link_attrs(cx, "/", prefetch);
163 let html = render(view! { cx => <a (attrs)></a> });
164 assert!(!seen.contains(&html), "{html}");
165 seen.push(html);
166 }
167 }
168
169 #[test]
170 fn link_renders_an_anchor_with_forwarded_attributes() {
171 let cx = &Cx::default();
172 let attrs = attributes! { cx => class="nav" aria-current="page" };
173 let html = render(view! { cx => link(href: "/account", attrs: attrs, "Account") });
174
175 assert!(html.starts_with("<a "), "{html}");
176 assert!(html.contains(r#"href="/account""#), "{html}");
177 assert!(html.contains(r#"class="nav""#), "{html}");
178 assert!(html.contains(r#"aria-current="page""#), "{html}");
179 assert!(html.contains(">Account</a>"), "{html}");
180 assert!(html.contains(r#"data-topcoat-link="intent""#), "{html}");
182 }
183
184 #[test]
185 fn explicit_props_take_precedence_over_forwarded_attributes() {
186 let cx = &Cx::default();
187 let attrs = attributes! { cx => href="/elsewhere" data-topcoat-link="viewport" };
188 let html = render(view! {
189 cx =>
190 link(href: "/account", prefetch: PrefetchMode::Never, attrs: attrs)
191 });
192
193 assert!(html.contains(r#"href="/account""#), "{html}");
194 assert!(!html.contains("/elsewhere"), "{html}");
195 assert!(!html.contains("viewport"), "{html}");
196 }
197
198 #[test]
199 fn prefetch_mode_prefers_the_request_context_over_the_app_context() {
200 let app = CxTestBuilder::new()
201 .app_context(PrefetchMode::Viewport)
202 .build();
203 assert_eq!(prefetch_mode(&app), PrefetchMode::Viewport);
204
205 let scoped = app.with(PrefetchMode::Never);
206 assert_eq!(prefetch_mode(&scoped), PrefetchMode::Never);
207 }
208
209 #[test]
210 fn link_uses_the_context_mode_unless_given_one() {
211 let cx = &CxTestBuilder::new()
212 .app_context(PrefetchMode::Viewport)
213 .build();
214 let html = render(view! { cx => link(href: "/", "Home") });
215 assert!(html.contains(r#"data-topcoat-link="viewport""#), "{html}");
216
217 let explicit = render(view! {
218 cx =>
219 link(href: "/", prefetch: PrefetchMode::Never, "Home")
220 });
221 assert!(
222 explicit.contains(r#"data-topcoat-link="never""#),
223 "{explicit}"
224 );
225 }
226}