1#![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
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]
55pub fn prefetch_mode(cx: &Cx) -> PrefetchMode {
56 try_context::<PrefetchMode>(cx).copied().unwrap_or_default()
57}
58
59#[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 href: H,
89 #[into]
92 #[default]
93 prefetch: Option<PrefetchMode>,
94 #[default]
96 mut attrs: Attributes,
97 #[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 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 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}