yggdrasil/components/post/
post_nav_links.rs1use leptos::ev::MouseEvent;
6use leptos::prelude::*;
7
8use crate::components::mascot::Mascot;
9use crate::components::ui::{Icon, IconName};
10use crate::models::post::PostNav;
11use crate::routes::Route;
12
13#[component]
23pub(super) fn PostNavLinks(
24 #[prop(into)] prev: Option<PostNav>,
25 #[prop(into)] next: Option<PostNav>,
26 #[prop(optional)] sample: bool,
27) -> impl IntoView {
28 if prev.is_none() && next.is_none() {
29 return ().into_any();
30 }
31 let card = move |post: PostNav, is_prev: bool| {
32 let class = if is_prev {
33 "post-nav-card prev"
34 } else {
35 "post-nav-card next"
36 };
37 let href = if sample {
38 "#".to_string()
39 } else {
40 Route::PostDetail { slug: post.slug }.to_string()
41 };
42 view! {
43 <a
44 class=class
45 href=href
46 on:click=move |event: MouseEvent| {
47 if sample {
48 event.prevent_default();
49 }
50 }
51 >
52 <PostNavLabel prev=is_prev/>
53 <span class="post-nav-title">{post.title}</span>
54 </a>
55 }
56 };
57 let lone = prev.is_some() != next.is_some();
58 view! {
59 <nav class="post-nav" aria-label="上一篇与下一篇">
60 {prev.map(|post| card(post, true))}
61 {next.map(|post| card(post, false))}
62 {lone.then(|| Mascot::Sit.img("mascot post-nav-mascot"))}
63 </nav>
64 }
65 .into_any()
66}
67
68#[component]
69fn PostNavLabel(prev: bool) -> impl IntoView {
70 view! {
71 <span class="post-nav-label">
72 {if prev {
73 view! {
74 <Icon name=IconName::ArrowLeft size=13/>
75 "上一篇"
76 }
77 .into_any()
78 } else {
79 view! {
80 "下一篇"
81 <Icon name=IconName::ArrowRight size=13/>
82 }
83 .into_any()
84 }}
85 </span>
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use leptos::reactive::owner::Owner;
93
94 fn nav(title: &str, slug: &str) -> PostNav {
95 PostNav {
96 title: title.into(),
97 slug: slug.into(),
98 }
99 }
100
101 #[test]
102 fn renders_links_to_neighbours_only() {
103 let html = Owner::new().with(|| {
104 view! { <PostNavLinks prev=None next=Some(nav("下一篇标题", "next-one"))/> }.to_html()
105 });
106 assert!(html.contains(r#"href="/post/next-one""#), "{html}");
107 assert!(html.contains("post-nav-card next"), "{html}");
108 assert!(!html.contains("post-nav-card prev"), "{html}");
109 }
110
111 #[test]
112 fn sample_mode_uses_inert_anchors_and_empty_nav_renders_nothing() {
113 let sample = Owner::new().with(|| {
114 view! { <PostNavLinks prev=Some(nav("上", "a")) next=None sample=true/> }.to_html()
115 });
116 assert!(sample.contains(r##"href="#""##), "{sample}");
117 assert!(!sample.contains("/post/a"), "{sample}");
118 let empty = Owner::new().with(|| view! { <PostNavLinks prev=None next=None/> }.to_html());
119 assert!(!empty.contains("post-nav"), "{empty}");
120 }
121
122 #[test]
123 fn mascot_fills_only_a_lone_neighbour_gap() {
124 let render = |prev: Option<PostNav>, next: Option<PostNav>| {
125 Owner::new().with(|| view! { <PostNavLinks prev=prev next=next/> }.to_html())
126 };
127 let only_prev = render(Some(nav("上", "a")), None);
128 assert!(only_prev.contains("post-nav-mascot"), "{only_prev}");
129 let only_next = render(None, Some(nav("下", "b")));
130 assert!(only_next.contains("post-nav-mascot"), "{only_next}");
131 let both = render(Some(nav("上", "a")), Some(nav("下", "b")));
132 assert!(!both.contains("post-nav-mascot"), "{both}");
133 }
134}