yew_bs/components/
pagination.rs

1use yew::prelude::*;
2use crate::components::common::Size;
3#[derive(Clone, Copy, PartialEq, Debug)]
4pub enum PaginationAlignment {
5    Start,
6    Center,
7    End,
8}
9impl PaginationAlignment {
10    pub fn as_str(&self) -> &'static str {
11        match self {
12            PaginationAlignment::Start => "justify-content-start",
13            PaginationAlignment::Center => "justify-content-center",
14            PaginationAlignment::End => "justify-content-end",
15        }
16    }
17}
18/// Props for the Pagination component
19#[derive(Properties, PartialEq)]
20pub struct PaginationProps {
21    /// Current active page (1-indexed)
22    pub active_page: usize,
23    /// Total number of pages
24    pub total_pages: usize,
25    /// Callback when page changes
26    #[prop_or_default]
27    pub on_page_change: Callback<usize>,
28    /// Pagination size
29    #[prop_or_default]
30    pub size: Option<Size>,
31    /// Pagination alignment
32    #[prop_or_default]
33    pub alignment: Option<PaginationAlignment>,
34    /// Maximum number of page buttons to show (excluding prev/next)
35    #[prop_or(7)]
36    pub max_page_buttons: usize,
37    /// Whether to show first/last buttons
38    #[prop_or(false)]
39    pub show_first_last: bool,
40    /// Text for previous button
41    #[prop_or("Previous".into())]
42    pub prev_text: AttrValue,
43    /// Text for next button
44    #[prop_or("Next".into())]
45    pub next_text: AttrValue,
46    /// Text for first button
47    #[prop_or("First".into())]
48    pub first_text: AttrValue,
49    /// Text for last button
50    #[prop_or("Last".into())]
51    pub last_text: AttrValue,
52    /// Aria label for navigation
53    #[prop_or("Page navigation".into())]
54    pub aria_label: AttrValue,
55    /// Additional CSS classes
56    #[prop_or_default]
57    pub class: Option<AttrValue>,
58}
59/// Bootstrap Pagination component
60#[function_component(Pagination)]
61pub fn pagination(props: &PaginationProps) -> Html {
62    if props.total_pages <= 1 {
63        return html! {};
64    }
65    let mut classes_vec = vec!["pagination".to_string()];
66    if let Some(size) = &props.size {
67        classes_vec.push(format!("pagination-{}", size.as_str()));
68    }
69    if let Some(class) = &props.class {
70        classes_vec.push(class.to_string());
71    }
72    let classes = classes!(classes_vec);
73    let on_page_change = props.on_page_change.clone();
74    let page_numbers = calculate_visible_pages(
75        props.active_page,
76        props.total_pages,
77        props.max_page_buttons,
78    );
79    html! {
80        < nav aria - label = { props.aria_label.clone() } > < ul class = { classes } > if
81        props.show_first_last { < li class = { classes!("page-item", if props.active_page
82        <= 1 { "disabled" } else { "" }) } > < a class = "page-link" href = "#" onclick =
83        { Callback::from({ let on_page_change = on_page_change.clone(); let active_page =
84        props.active_page; move | e : MouseEvent | { e.prevent_default(); if active_page
85        > 1 { on_page_change.emit(1); } } }) } aria - label = "First page" > { props
86        .first_text.clone() } </ a > </ li > } < li class = { classes!("page-item", if
87        props.active_page <= 1 { "disabled" } else { "" }) } > < a class = "page-link"
88        href = "#" onclick = { Callback::from({ let on_page_change = on_page_change
89        .clone(); let active_page = props.active_page; move | e : MouseEvent | { e
90        .prevent_default(); if active_page > 1 { on_page_change.emit(active_page - 1); }
91        } }) } aria - label = "Previous page" > { props.prev_text.clone() } </ a > </ li
92        > { page_numbers.into_iter().map(| page_num | { let is_active = page_num == props
93        .active_page; let is_ellipsis = page_num == 0; if is_ellipsis { html! { < li
94        class = "page-item disabled" > < span class = "page-link" > { "…" } </ span >
95        </ li > } } else { let on_click = { let on_page_change = props.on_page_change
96        .clone(); Callback::from(move | e : MouseEvent | { e.prevent_default();
97        on_page_change.emit(page_num); }) }; html! { < li class = { classes!("page-item",
98        is_active.then_some("active")) } > < a class = "page-link" href = "#" onclick = {
99        on_click } aria - label = { format!("Page {}", page_num) } aria - current = {
100        is_active.then_some("page") } > { page_num.to_string() } </ a > </ li > } } })
101        .collect::< Html > () } < li class = { classes!("page-item", if props.active_page
102        >= props.total_pages { "disabled" } else { "" }) } > < a class = "page-link" href
103        = "#" onclick = { Callback::from({ let on_page_change = on_page_change.clone();
104        let active_page = props.active_page; let total_pages = props.total_pages; move |
105        e : MouseEvent | { e.prevent_default(); if active_page < total_pages {
106        on_page_change.emit(active_page + 1); } } }) } aria - label = "Next page" > {
107        props.next_text.clone() } </ a > </ li > if props.show_first_last { < li class =
108        { classes!("page-item", if props.active_page >= props.total_pages { "disabled" }
109        else { "" }) } > < a class = "page-link" href = "#" onclick = { Callback::from({
110        let on_page_change = on_page_change.clone(); let active_page = props.active_page;
111        let total_pages = props.total_pages; move | e : MouseEvent | { e
112        .prevent_default(); if active_page < total_pages { on_page_change
113        .emit(total_pages); } } }) } aria - label = "Last page" > { props.last_text
114        .clone() } </ a > </ li > } </ ul > </ nav >
115    }
116}
117/// Calculate which page numbers should be visible
118fn calculate_visible_pages(
119    active_page: usize,
120    total_pages: usize,
121    max_buttons: usize,
122) -> Vec<usize> {
123    if total_pages <= max_buttons {
124        return (1..=total_pages).collect();
125    }
126    let mut pages = Vec::new();
127    let half_max = max_buttons / 2;
128    pages.push(1);
129    let start = (active_page.saturating_sub(half_max)).max(2);
130    let end = (active_page + half_max).min(total_pages - 1);
131    if start > 2 {
132        pages.push(0);
133    }
134    for page in start..=end {
135        pages.push(page);
136    }
137    if end < total_pages - 1 {
138        pages.push(0);
139    }
140    if total_pages > 1 {
141        pages.push(total_pages);
142    }
143    pages
144}
145#[cfg(test)]
146mod tests {
147    use super::*;
148    #[test]
149    fn test_calculate_visible_pages_small() {
150        assert_eq!(calculate_visible_pages(1, 5, 7), vec![1, 2, 3, 4, 5]);
151    }
152    #[test]
153    fn test_calculate_visible_pages_start() {
154        assert_eq!(calculate_visible_pages(2, 10, 5), vec![1, 2, 3, 4, 0, 10]);
155    }
156    #[test]
157    fn test_calculate_visible_pages_middle() {
158        assert_eq!(calculate_visible_pages(5, 10, 5), vec![1, 0, 3, 4, 5, 6, 7, 0, 10]);
159    }
160    #[test]
161    fn test_calculate_visible_pages_end() {
162        assert_eq!(calculate_visible_pages(9, 10, 5), vec![1, 0, 7, 8, 9, 10]);
163    }
164}