Skip to main content

yggdrasil/components/ui/
navigation.rs

1//! Pagination, tabs and navigable tag chips.
2use super::controls::{ButtonSize, IconButton};
3use super::icons::{Icon, IconName};
4use leptos::ev::MouseEvent;
5use leptos::prelude::*;
6
7/// 前台列表分页:上一页 / 下一页描边按钮与居中的「当前 / 总」页码,首尾页不渲染越界按钮。
8///
9/// Props:
10/// - `current_page`:当前页码(从 1 开始)
11/// - `total`:数据总条数
12/// - `per_page`:每页条数,用于计算总页数
13/// - `prev_route`:上一页的目标地址(站内路径,如 `Route::HomePage { page: 1 }.to_string()`;
14///   是普通 `<a href>`,由路由协调器做客户端导航)
15/// - `next_route`:下一页的目标地址
16///
17/// `current_page` / `total` / `prev_route` / `next_route` 既可传值也可传信号或闭包。
18#[component]
19pub fn Pagination(
20    #[prop(into)] current_page: Signal<i32>,
21    #[prop(into)] total: Signal<i64>,
22    per_page: i32,
23    #[prop(into)] prev_route: Signal<String>,
24    #[prop(into)] next_route: Signal<String>,
25) -> impl IntoView {
26    let total_pages =
27        Memo::new(move |_| ((total.get() + per_page as i64 - 1) / per_page as i64).max(1) as i32);
28    let has_prev = move || current_page.get() > 1;
29    let has_next = move || current_page.get() < total_pages.get();
30
31    view! {
32        <nav class="frontend-pagination" aria-label="分页导航">
33            {move || {
34                has_prev()
35                    .then(|| {
36                        view! {
37                            <a class="btn btn-sm btn-outline" href=prev_route.get()>
38                                <Icon name=IconName::ChevronLeft size=15/>
39                                "上一页"
40                            </a>
41                        }
42                    })
43            }}
44            <span
45                class="col-start-2 row-start-1 text-xs text-fg-3 whitespace-nowrap tabular-nums"
46                aria-label=move || format!("第 {} 页,共 {} 页", current_page.get(), total_pages.get())
47                aria-current="page"
48            >
49                {move || format!("{} / {}", current_page.get(), total_pages.get())}
50            </span>
51            {move || {
52                has_next()
53                    .then(|| {
54                        view! {
55                            <a
56                                class="btn btn-sm btn-outline col-start-3 row-start-1 justify-self-end"
57                                href=next_route.get()
58                            >
59                                "下一页"
60                                <Icon name=IconName::ChevronRight size=15/>
61                            </a>
62                        }
63                    })
64            }}
65        </nav>
66    }
67}
68
69/// 表格底栏分页:左侧「第 21–40 篇,共 57 篇」,右侧上一页、页码与下一页图标按钮。
70/// 只有一页时只显示总数。
71///
72/// Props:
73/// - `page`:当前页(从 1 开始);`total`:总条数;`per_page`:每页条数
74/// - `unit`:计数单位,如「篇」「条」
75/// - `on_change`:翻到的页码
76#[component]
77pub fn TablePager(
78    #[prop(into)] page: Signal<i32>,
79    #[prop(into)] total: Signal<i64>,
80    per_page: i32,
81    #[prop(into)] unit: String,
82    #[prop(into)] on_change: Callback<i32>,
83) -> impl IntoView {
84    let pages =
85        Memo::new(move |_| ((total.get() + per_page as i64 - 1) / per_page as i64).max(1) as i32);
86    let page = Memo::new(move |_| page.get().clamp(1, pages.get()));
87    let range = move || {
88        let total = total.get();
89        let pages = pages.get();
90        let page = page.get();
91        let first = (page as i64 - 1) * per_page as i64 + 1;
92        let last = (page as i64 * per_page as i64).min(total);
93        if total == 0 {
94            format!("共 0 {unit}")
95        } else if pages == 1 {
96            format!("共 {total} {unit}")
97        } else {
98            format!("第 {first}–{last} {unit},共 {total} {unit}")
99        }
100    };
101    view! {
102        <nav class="table-pager" aria-label="分页导航">
103            <span class="table-pager-range">{range}</span>
104            <Show when=move || { pages.get() > 1 }>
105                <div class="table-pager-controls">
106                    <IconButton
107                        icon=IconName::ChevronLeft
108                        label="上一页"
109                        size=ButtonSize::Sm
110                        disabled=Signal::derive(move || page.get() <= 1)
111                        onclick=Callback::new(move |_| on_change.run(page.get_untracked() - 1))
112                    />
113                    <span class="table-pager-page" aria-current="page">
114                        {move || format!("{} / {}", page.get(), pages.get())}
115                    </span>
116                    <IconButton
117                        icon=IconName::ChevronRight
118                        label="下一页"
119                        size=ButtonSize::Sm
120                        tooltip_align="end"
121                        disabled=Signal::derive(move || page.get() >= pages.get())
122                        onclick=Callback::new(move |_| on_change.run(page.get_untracked() + 1))
123                    />
124                </div>
125            </Show>
126        </nav>
127    }
128}
129
130/// 面包屑中的一级。最后一级是当前页(不可点击,标 `aria-current`);中间级 `to` 为
131/// `None` 时是不可点击的分组名(如后台的「内容」)。
132///
133/// `to` 是站内路径(`<a href>`),原先泛型的 `Route` 由调用方 `.to_string()` 转成地址;
134/// [`Crumb::link`] 直接接受任何 `ToString`(含 `Route`)。
135#[derive(Clone, Debug, PartialEq)]
136pub struct Crumb {
137    pub label: String,
138    pub to: Option<String>,
139}
140
141impl Crumb {
142    pub fn link(label: impl Into<String>, to: impl ToString) -> Self {
143        Self {
144            label: label.into(),
145            to: Some(to.to_string()),
146        }
147    }
148
149    pub fn current(label: impl Into<String>) -> Self {
150        Self {
151            label: label.into(),
152            to: None,
153        }
154    }
155
156    /// 不可点击的中间级(分组名)。
157    pub fn text(label: impl Into<String>) -> Self {
158        Self::current(label)
159    }
160}
161
162/// 面包屑:`关于 / 组件图鉴 / 复选框`,最后一级为当前页并在过长时截断。
163///
164/// `items` 既可传 `Vec<Crumb>`,也可传信号或闭包。
165#[component]
166pub fn Breadcrumb(#[prop(into)] items: Signal<Vec<Crumb>>) -> impl IntoView {
167    view! {
168        <nav class="breadcrumb" aria-label="面包屑">
169            {move || {
170                let items = items.get();
171                let last = items.len().saturating_sub(1);
172                items
173                    .into_iter()
174                    .enumerate()
175                    .map(|(i, crumb)| {
176                        let separator = (i > 0).then(|| view! { <span aria-hidden="true">"/"</span> });
177                        let node = match crumb.to {
178                            Some(to) if i < last => {
179                                view! { <a href=to>{crumb.label}</a> }.into_any()
180                            }
181                            None if i < last => view! { <span>{crumb.label}</span> }.into_any(),
182                            _ => {
183                                view! { <span aria-current="page">{crumb.label}</span> }.into_any()
184                            }
185                        };
186                        view! {
187                            {separator}
188                            {node}
189                        }
190                    })
191                    .collect_view()
192            }}
193        </nav>
194    }
195}
196
197/// 标签芯片组件:统一软底标签、卡片胶囊与文字链接的展示。
198///
199/// 各处跳转到 `Route::TagDetail` 的可点击标签共用此组件,仅以 `variant` 区分视觉。
200///
201/// Props:
202/// - `label`:标签名
203/// - `to`:跳转目标地址(站内路径,调用方传 `Route::TagDetail { .. }.to_string()`,原子层不绑定 app 路由类型)
204/// - `variant`:`"solid"`(软底)/ `"outline"`(卡片胶囊,默认)/ `"text"`(紧凑文章流)
205/// - `count`:可选的文章计数
206/// - `stop_propagation`:是否阻止点击冒泡(卡片内覆盖层链接场景需要)。注意:冒泡被拦截后,
207///   文档级的路由协调器收不到这次点击,链接会退化为整页跳转。
208#[component]
209pub fn TagChip(
210    #[prop(into)] label: String,
211    #[prop(into)] to: String,
212    #[prop(default = "outline".to_string(), into)] variant: String,
213    #[prop(optional)] count: Option<i64>,
214    #[prop(optional)] stop_propagation: bool,
215) -> impl IntoView {
216    let class = match variant.as_str() {
217        "solid" => "chip chip-clay",
218        "text" => "inline-flex items-center py-1 text-fg-2 hover:text-fg underline decoration-hairline underline-offset-4 hover:decoration-clay transition-colors focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-clay",
219        _ => "chip",
220    };
221    view! {
222        <a
223            class=class
224            href=to
225            on:click=move |evt: MouseEvent| {
226                if stop_propagation {
227                    evt.stop_propagation();
228                }
229            }
230        >
231            {label}
232            {count.map(|c| view! { <span class="chip-count">{c}</span> })}
233        </a>
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn render<V: IntoView>(build: impl FnOnce() -> V) -> String {
242        Owner::new().with(|| build().to_html())
243    }
244
245    #[test]
246    fn pagination_hides_out_of_range_buttons() {
247        let first = render(|| {
248            view! {
249                <Pagination current_page=1 total=45i64 per_page=20 prev_route="/page/0" next_route="/page/2"/>
250            }
251        });
252        assert!(first.contains("href=\"/page/2\""), "{first}");
253        assert!(!first.contains("上一页"), "{first}");
254        assert!(first.contains("1 / 3"), "{first}");
255        let last = render(|| {
256            view! {
257                <Pagination current_page=3 total=45i64 per_page=20 prev_route="/page/2" next_route="/page/4"/>
258            }
259        });
260        assert!(last.contains("href=\"/page/2\""), "{last}");
261        assert!(!last.contains("下一页"), "{last}");
262    }
263
264    #[test]
265    fn table_pager_shows_range_and_controls_only_for_many_pages() {
266        let many = render(|| {
267            view! {
268                <TablePager page=2 total=57i64 per_page=20 unit="篇" on_change=|_| {}/>
269            }
270        });
271        assert!(many.contains("第 21–40 篇,共 57 篇"), "{many}");
272        assert!(many.contains("table-pager-controls"), "{many}");
273        let one = render(|| {
274            view! {
275                <TablePager page=1 total=3i64 per_page=20 unit="篇" on_change=|_| {}/>
276            }
277        });
278        assert!(one.contains("共 3 篇"), "{one}");
279        assert!(!one.contains("table-pager-controls"), "{one}");
280    }
281
282    #[test]
283    fn breadcrumb_marks_last_crumb_as_current_page() {
284        let html = render(|| {
285            view! {
286                <Breadcrumb items=vec![
287                    Crumb::link("关于", "/about"),
288                    Crumb::text("分组"),
289                    Crumb::current("复选框"),
290                ]/>
291            }
292        });
293        assert!(html.contains("href=\"/about\""), "{html}");
294        assert!(html.contains("aria-current=\"page\""), "{html}");
295        assert_eq!(html.matches("aria-hidden=\"true\"").count(), 2, "{html}");
296    }
297
298    #[test]
299    fn tag_chip_renders_variant_class_and_count() {
300        let html = render(
301            || view! { <TagChip label="设计" to="/tags/设计" variant="solid" count=12i64/> },
302        );
303        assert!(html.contains("chip chip-clay"), "{html}");
304        assert!(html.contains("chip-count"), "{html}");
305    }
306}