yggdrasil/components/ui/
navigation.rs1use super::controls::{ButtonSize, IconButton};
3use super::icons::{Icon, IconName};
4use leptos::ev::MouseEvent;
5use leptos::prelude::*;
6
7#[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#[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#[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 pub fn text(label: impl Into<String>) -> Self {
158 Self::current(label)
159 }
160}
161
162#[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#[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}