1use hypersig::html::{El, a, li, nav, ol, span, ul};
5use hypersig::style::pattern::{hstack, stack};
6use hypersig::style::value::{AlignItems, FlexWrap, JustifyContent, pct, rem};
7use hypersig::style::{PseudoClass, Style};
8use hypersig::{Link, Params, Route, Search, Url};
9use serde::Serialize;
10
11use crate::button::{ButtonSize, ButtonVariant, button_style};
12use crate::look::{self, icon};
13use crate::token;
14
15#[derive(Debug)]
48pub struct Breadcrumb<S> {
49 label: String,
50 links: Vec<(String, Link<S>)>,
51}
52
53impl<S> Breadcrumb<S> {
54 #[must_use]
56 pub fn new(label: impl Into<String>) -> Self {
57 Self {
58 label: label.into(),
59 links: Vec::new(),
60 }
61 }
62
63 #[must_use]
65 pub fn link(mut self, text: impl Into<String>, link: Link<S>) -> Self {
66 self.links.push((text.into(), link));
67 self
68 }
69
70 #[must_use]
72 pub fn current(self, text: impl Into<String>) -> El<S> {
73 let separator = || {
74 li().attr("role", "presentation")
75 .attr("aria-hidden", "true")
76 .css(Style::new().display(hypersig::style::value::Display::InlineFlex))
77 .child(icon(look::icon::CHEVRON_RIGHT, rem(0.875)))
78 };
79 let mut list = ol().css(
80 hstack(rem(0.375))
81 .flex_wrap(FlexWrap::Wrap)
82 .merge(look::small_text())
83 .color(token::muted_foreground().var()),
84 );
85 for (text, link) in self.links {
86 list = list
87 .child(
88 li().child(
89 a().href(link)
90 .css(
91 Style::new()
92 .merge(look::transition())
93 .merge(look::focus_ring())
94 .on(
95 PseudoClass::Hover,
96 Style::new().color(token::foreground().var()),
97 ),
98 )
99 .text(text),
100 ),
101 )
102 .child(separator());
103 }
104 list = list.child(
105 li().child(
106 span()
107 .attr("aria-current", "page")
108 .css(Style::new().color(token::foreground().var()))
109 .text(text),
110 ),
111 );
112 nav().attr("aria-label", self.label).child(list)
113 }
114}
115
116#[derive(Debug)]
153pub struct NavList<S> {
154 label: String,
155 current: Url,
156 items: Vec<(String, Link<S>, Url)>,
157}
158
159impl<S> NavList<S> {
160 #[must_use]
162 pub fn new(label: impl Into<String>, current: Url) -> Self {
163 Self {
164 label: label.into(),
165 current,
166 items: Vec::new(),
167 }
168 }
169
170 #[must_use]
173 pub fn item<P: Params, Q: Search>(
174 mut self,
175 text: impl Into<String>,
176 route: Route<P, Q>,
177 args: Q::Args<P>,
178 ) -> Self
179 where
180 Q::Args<P>: Serialize,
181 {
182 let link = route.link_with(&args);
183 self.items.push((text.into(), link, route.url(args)));
184 self
185 }
186
187 #[must_use]
189 pub fn render(self) -> El<S> {
190 let path = |url: &Url| {
191 let url = url.as_str();
192 url.split_once('?').map_or(url, |(path, _)| path).to_owned()
193 };
194 let current = path(&self.current);
195 let items = self.items.into_iter().map(|(text, link, url)| {
196 let target = path(&url);
197 let mark = if target == current {
198 Some("page")
199 } else if current
200 .strip_prefix(target.trim_end_matches('/'))
201 .is_some_and(|rest| rest.starts_with('/') && target != "/")
202 {
203 Some("true")
204 } else {
205 None
206 };
207 let mut style = button_style(ButtonVariant::Ghost, ButtonSize::Sm)
208 .width(pct(100))
209 .justify_content(JustifyContent::FlexStart);
210 if mark.is_some() {
211 style = style.merge(
212 Style::new()
213 .background_color(token::accent().var())
214 .color(token::accent_foreground().var()),
215 );
216 }
217 let mut item = a().href(link).css(style).text(text);
218 if let Some(mark) = mark {
219 item = item.attr("aria-current", mark);
220 }
221 li().child(item)
222 });
223 nav()
224 .attr("aria-label", self.label)
225 .child(ul().css(stack(rem(0.25))).children(items))
226 }
227}
228
229impl<S> From<NavList<S>> for El<S> {
230 fn from(list: NavList<S>) -> Self {
231 list.render()
232 }
233}
234
235impl<S> From<NavList<S>> for hypersig::html::Node<S> {
236 fn from(list: NavList<S>) -> Self {
237 list.render().into()
238 }
239}
240
241#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct PaginationText {
258 pub label: String,
260 pub previous: String,
262 pub next: String,
264}
265
266impl Default for PaginationText {
267 fn default() -> Self {
268 Self {
269 label: "Pagination".to_owned(),
270 previous: "Previous".to_owned(),
271 next: "Next".to_owned(),
272 }
273 }
274}
275
276#[must_use]
310pub fn pagination<S>(
311 current: u32,
312 count: u32,
313 link: impl Fn(u32) -> Link<S>,
314 text: &PaginationText,
315) -> El<S> {
316 assert!(
317 current >= 1 && current <= count,
318 "the current page is between 1 and the number of pages"
319 );
320 let square = |variant| {
321 button_style(variant, ButtonSize::Default)
322 .width(rem(2.25))
323 .padding_inline(rem(0))
324 };
325 let step = |target: Option<u32>, label: &str, rel: &'static str, back: bool| {
326 let arrow = icon(
327 if back {
328 look::icon::CHEVRON_LEFT
329 } else {
330 look::icon::CHEVRON_RIGHT
331 },
332 rem(1),
333 );
334 let style =
335 button_style(ButtonVariant::Ghost, ButtonSize::Default).padding_inline(rem(0.625));
336 let el = match target {
337 Some(page) => a().href(link(page)).attr("rel", rel).css(style),
338 None => span().attr("aria-disabled", "true").css(
339 style
340 .opacity(hypersig::style::value::Opacity::Number(0.5))
341 .cursor(hypersig::style::value::Cursor::NotAllowed),
342 ),
343 };
344 let el = if back {
345 el.child(arrow).child(span().text(label))
346 } else {
347 el.child(span().text(label)).child(arrow)
348 };
349 li().child(el)
350 };
351
352 let mut list = ul()
353 .css(
354 hstack(rem(0.25))
355 .flex_wrap(FlexWrap::Wrap)
356 .align_items(AlignItems::Center),
357 )
358 .child(step(
359 (current > 1).then(|| current - 1),
360 &text.previous,
361 "prev",
362 true,
363 ));
364 let mut shown = 0;
365 for page in 1..=count {
366 let near = page.abs_diff(current) <= 1;
367 let fills_gap = (page == 2 && current == 4) || (page + 1 == count && current + 3 == count);
369 if !(page == 1 || page == count || near || fills_gap) {
370 continue;
371 }
372 if page > shown + 1 {
373 list = list.child(
374 li().attr("aria-hidden", "true")
375 .css(
376 Style::new()
377 .width(rem(2.25))
378 .text_align(hypersig::style::value::TextAlign::Center),
379 )
380 .text("…"),
381 );
382 }
383 shown = page;
384 let mut item = a().href(link(page));
385 if page == current {
386 item = item
387 .attr("aria-current", "page")
388 .css(square(ButtonVariant::Outline));
389 } else {
390 item = item.css(square(ButtonVariant::Ghost));
391 }
392 list = list.child(li().child(item.text(page.to_string())));
393 }
394 list = list.child(step(
395 (current < count).then(|| current + 1),
396 &text.next,
397 "next",
398 false,
399 ));
400 nav()
401 .attr("aria-label", text.label.clone())
402 .css(
403 Style::new()
404 .display(hypersig::style::value::Display::Flex)
405 .justify_content(JustifyContent::Center),
406 )
407 .child(list)
408}
409
410#[cfg(test)]
411mod tests {
412 use hypersig::{Page, Route, Signals};
413 use serde::{Deserialize, Serialize};
414
415 use super::{PaginationText, pagination};
416
417 #[derive(Signals, Serialize, Deserialize)]
418 struct Catalog {
419 query: String,
420 }
421
422 async fn page((): (), _: u32) -> Option<Page<Catalog>> {
423 None
424 }
425
426 hypersig::routes! {
427 fn pages(Router<()>);
428 const PRODUCTS: Route<u32> = "/products/{page}" => page;
429 }
430
431 fn shown(current: u32, count: u32) -> Vec<u32> {
433 let _ = pages;
434 let html = pagination::<Catalog>(
435 current,
436 count,
437 |page| PRODUCTS.link_with(&page),
438 &PaginationText::default(),
439 )
440 .render();
441 let mut out = Vec::new();
442 for part in html.split("<li").skip(1) {
443 if part.contains("…") && !part.contains("href") {
444 out.push(0);
445 } else if let Some(rest) = part.split(r#"href="/products/"#).nth(1)
446 && !part.contains("rel=")
447 {
448 out.push(rest.split('"').next().unwrap().parse().unwrap());
449 }
450 }
451 out
452 }
453
454 #[test]
455 fn a_gap_of_one_page_shows_the_page() {
456 assert_eq!(shown(1, 1), [1]);
457 assert_eq!(shown(1, 3), [1, 2, 3]);
458 assert_eq!(shown(4, 10), [1, 2, 3, 4, 5, 0, 10]);
459 assert_eq!(shown(5, 10), [1, 0, 4, 5, 6, 0, 10]);
460 assert_eq!(shown(7, 10), [1, 0, 6, 7, 8, 9, 10]);
461 assert_eq!(shown(10, 10), [1, 0, 9, 10]);
462 }
463
464 #[test]
465 #[should_panic(expected = "between 1 and the number of pages")]
466 fn a_page_past_the_last_panics() {
467 let _ = shown(4, 3);
468 }
469}