Skip to main content

yggdrasil/components/post/
post_nav_links.rs

1//! 文章上一篇/下一篇导航组件
2//!
3//! 在文章详情页底部提供相邻文章的快速跳转。
4
5use 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/// 文章相邻导航组件。
14///
15/// Props:
16/// - `prev`:上一篇文章摘要
17/// - `next`:下一篇文章摘要
18/// - `sample`:图鉴使用不可导航的样例锚点,默认 false。
19///
20/// 两张并排卡片,下一篇右对齐;窄容器中纵向排列。
21/// 无相邻文章时不渲染对应链接,两侧都缺失时不渲染导航。
22#[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}