Skip to main content

fc_dioxus/components/
pagination.rs

1//! Numbered page navigation, from pagination.css.
2use crate::{
3    core::{assets, attrs::component_attributes},
4    prelude::*,
5    theme::use_stylesheets,
6};
7const BEM: Bem = Bem::new("fc-pagination");
8const LINK: Bem = Bem::new("fc-pagination__link");
9fc_modifier! { enum PageDirection for LINK { Page => None, Prev => "prev", Next => "next" } }
10
11/// One entry in the page list.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum PageItem {
14    /// A numbered page, counted from 1.
15    Page(u32),
16    /// Skipped pages, drawn as an ellipsis.
17    Gap,
18}
19
20/// The pages to show: the first and last, `siblings` either side of `page`, and a gap
21/// wherever pages are skipped. A gap that would hide a single page shows that page
22/// instead, because an ellipsis standing for one number saves nothing.
23///
24/// ```
25/// use fc_dioxus::components::pagination::{page_window, PageItem::{Gap, Page}};
26/// assert_eq!(page_window(1, 12, 1), [Page(1), Page(2), Gap, Page(12)]);
27/// assert_eq!(page_window(6, 12, 1), [Page(1), Gap, Page(5), Page(6), Page(7), Gap, Page(12)]);
28/// assert_eq!(page_window(3, 12, 1), [Page(1), Page(2), Page(3), Page(4), Gap, Page(12)]);
29/// assert_eq!(page_window(1, 0, 1), []);
30/// ```
31#[must_use]
32pub fn page_window(page: u32, pages: u32, siblings: u32) -> Vec<PageItem> {
33    if pages == 0 {
34        return Vec::new();
35    }
36    let page = page.clamp(1, pages);
37    let low = page.saturating_sub(siblings).max(1);
38    let high = page.saturating_add(siblings).min(pages);
39    let mut shown: Vec<u32> = vec![1];
40    shown.extend(low..=high);
41    shown.push(pages);
42    shown.sort_unstable();
43    shown.dedup();
44    let mut items = Vec::with_capacity(shown.len() + 2);
45    let mut previous = 0;
46    for number in shown {
47        match number - previous {
48            1 => {}
49            2 => items.push(PageItem::Page(previous + 1)),
50            _ => items.push(PageItem::Gap),
51        }
52        items.push(PageItem::Page(number));
53        previous = number;
54    }
55    items
56}
57
58/// Previous and next links around a list of pages.
59///
60/// Give `href` to render crawlable links, or `onchange` to page within the app. The
61/// items default to [`page_window`]; pass `items` to choose them yourself.
62#[component]
63pub fn Pagination(
64    label: AccessibleName,
65    page: u32,
66    pages: u32,
67    #[props(default = 1)] siblings: u32,
68    #[props(default)] items: Option<Vec<PageItem>>,
69    #[props(default)] href: Option<Callback<u32, String>>,
70    #[props(default)] onchange: Option<EventHandler<u32>>,
71    #[props(default = "Previous".to_owned())] previous: String,
72    #[props(default = "Next".to_owned())] next: String,
73    #[props(default)] class: Option<String>,
74    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
75) -> Element {
76    let attributes = component_attributes(
77        attributes,
78        ClassList::new(BEM.block()).extend(class.as_deref()),
79        &["aria-label"],
80    );
81    let items = items.unwrap_or_else(|| page_window(page, pages, siblings));
82    let link =
83        move |target: u32, direction: PageDirection, enabled: bool, current: bool, text: String| {
84            let class = ClassList::new(LINK.block())
85                .with_modifier(LINK, direction)
86                .to_string();
87            let availability = if enabled {
88                Availability::Enabled
89            } else {
90                Availability::Disabled
91            };
92            let url = href.map(|href| href.call(target));
93            let onclick = onchange.map(|onchange| {
94                EventHandler::new(move |event: MouseEvent| {
95                    // In-app paging replaces navigation; a link with an href still works
96                    // when opened in a new tab.
97                    event.prevent_default();
98                    onchange.call(target);
99                })
100            });
101            let url = url.or_else(|| onclick.is_some().then(|| format!("#page-{target}")));
102            super::nav_target(
103                class,
104                url,
105                onclick,
106                current,
107                &availability,
108                Vec::new(),
109                rsx! { "{text}" },
110            )
111        };
112    rsx! {
113        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_PAGINATION_CSS } }
114        nav { aria_label: label.to_string(), ..attributes,
115            ul { class: BEM.element("list"),
116                li { {link(page.saturating_sub(1), PageDirection::Prev, page > 1, false, previous)} }
117                for (index, item) in items.into_iter().enumerate() {
118                    li { key: "{index}",
119                        match item {
120                            PageItem::Page(number) => link(number, PageDirection::Page, true, number == page, number.to_string()),
121                            PageItem::Gap => rsx! { span { class: BEM.element("gap"), aria_hidden: "true", "…" } },
122                        }
123                    }
124                }
125                li { {link(page.saturating_add(1), PageDirection::Next, page < pages, false, next)} }
126            }
127        }
128    }
129}