Skip to main content

yew_nav_link/components/
pagination.rs

1//! # Pagination
2//!
3//! Page navigation component with prev/next buttons, first/last shortcuts,
4//! and configurable sibling pages with ellipsis gaps.
5//!
6//! # Example
7//!
8//! ```rust
9//! use yew::prelude::*;
10//! use yew_nav_link::components::Pagination;
11//!
12//! #[component]
13//! fn Paginator() -> Html {
14//!     let page = use_state(|| 1u32);
15//!     let on_change = {
16//!         let page = page.clone();
17//!         Callback::from(move |p: u32| page.set(p))
18//!     };
19//!
20//!     html! {
21//!         <Pagination
22//!             current_page={*page}
23//!             total_pages={20}
24//!             siblings={2}
25//!             show_prev_next={true}
26//!             on_page_change={Some(on_change)}
27//!         />
28//!     }
29//! }
30//! ```
31//!
32//! # CSS Classes
33//!
34//! | Class | Condition |
35//! |-------|-----------|
36//! | `pagination` | Container `<ul>` element |
37//! | `pagination-item` | Each `<li>` page button wrapper |
38//! | `active` | Applied to the current page button |
39//!
40//! # Props
41//!
42//! | Prop | Type | Default | Description |
43//! |------|------|---------|-------------|
44//! | `current_page` | `u32` | `1` | Currently active page (1-indexed) |
45//! | `total_pages` | `u32` | `10` | Total number of pages |
46//! | `siblings` | `u32` | `1` | Pages shown on each side of current |
47//! | `show_prev_next` | `bool` | `true` | Show prev/next buttons |
48//! | `show_first_last` | `bool` | `false` | Show first/last page buttons |
49//! | `on_page_change` | `Option<Callback<u32>>` | `None` | Page change callback |
50//! | `classes` | `Classes` | — | Additional CSS classes |
51
52use yew::prelude::*;
53
54use super::pagination_page::generate_pages;
55
56/// Properties for the [`Pagination`] component.
57///
58/// | Prop | Type | Default | Description |
59/// |------|------|---------|-------------|
60/// | `current_page` | `u32` | `1` | Currently active page (1-indexed) |
61/// | `total_pages` | `u32` | `10` | Total number of pages |
62/// | `siblings` | `u32` | `1` | Pages shown on each side of current |
63/// | `show_prev_next` | `bool` | `true` | Show prev/next buttons |
64/// | `show_first_last` | `bool` | `false` | Show first/last page buttons |
65/// | `on_page_change` | `Option<Callback<u32>>` | `None` | Page change callback |
66/// | `classes` | `Classes` | — | Additional CSS classes |
67#[derive(Properties, Clone, PartialEq, Debug)]
68pub struct PaginationProps {
69    /// Additional CSS classes applied to the pagination container.
70    #[prop_or_default]
71    pub classes: Classes,
72
73    /// The currently active page number (1-indexed).
74    #[prop_or(1)]
75    pub current_page: u32,
76
77    /// Total number of pages available.
78    #[prop_or(10)]
79    pub total_pages: u32,
80
81    /// Number of sibling pages to show on each side of the current page.
82    #[prop_or(1)]
83    pub siblings: u32,
84
85    /// Whether to show first and last page buttons.
86    #[prop_or(false)]
87    pub show_first_last: bool,
88
89    /// Whether to show previous and next navigation buttons.
90    #[prop_or(true)]
91    pub show_prev_next: bool,
92
93    /// Callback invoked with the new page number when a page is selected.
94    #[prop_or_default]
95    pub on_page_change: Option<Callback<u32>>
96}
97
98impl Default for PaginationProps {
99    fn default() -> Self {
100        Self {
101            classes:         Classes::default(),
102            current_page:    1,
103            total_pages:     10,
104            siblings:        1,
105            show_first_last: false,
106            show_prev_next:  true,
107            on_page_change:  None
108        }
109    }
110}
111
112/// Pagination component for navigating between pages of content.
113///
114/// Renders a `<nav>` with page buttons and optional prev/next and
115/// first/last navigation controls.
116///
117/// # CSS Classes
118///
119/// - `pagination` - Container `<ul>` element
120/// - `pagination-item` - Each `<li>` page button wrapper
121/// - `active` - Applied to the current page button
122#[function_component]
123pub fn Pagination(props: &PaginationProps) -> Html {
124    let mut classes = props.classes.clone();
125    classes.push("pagination");
126
127    let pages = generate_pages(props.current_page, props.total_pages, props.siblings);
128    let on_page_change = props.on_page_change.clone();
129    let current_page = props.current_page;
130    let total_pages = props.total_pages;
131    let show_prev_next = props.show_prev_next;
132    let show_first_last = props.show_first_last;
133
134    html! {
135        <nav aria-label="pagination">
136            <ul {classes}>
137                if show_prev_next {
138                    <li class="pagination-item">
139                        <button
140                            type="button"
141                            disabled={current_page <= 1}
142                            onclick={on_page_change.clone().map(move |cb| {
143                                let cb = cb.clone();
144                                move |_: MouseEvent| cb.emit(current_page.saturating_sub(1))
145                            })}
146                        >
147                            {"‹"}
148                        </button>
149                    </li>
150                }
151
152                if show_first_last {
153                    <li class="pagination-item">
154                        <button
155                            type="button"
156                            disabled={current_page == 1}
157                            onclick={on_page_change.clone().map(move |cb| {
158                                let cb = cb.clone();
159                                move |_: MouseEvent| cb.emit(1)
160                            })}
161                        >
162                            {"1"}
163                        </button>
164                    </li>
165                }
166
167                { for pages.iter().map(|page| {
168                    let onclick = on_page_change.clone().map(move |cb| {
169                        let cb = cb.clone();
170                        let page_num = *page;
171                        move |_: MouseEvent| cb.emit(page_num)
172                    });
173
174                    let is_active = *page == current_page;
175                    let is_disabled = is_active || *page == 0;
176
177                    html! {
178                        <li class={classes!("pagination-item", if is_active { "active" } else { "" })}>
179                            <button
180                                type="button"
181                                disabled={is_disabled}
182                                aria-current={if is_active { "page" } else { "false" }}
183                                {onclick}
184                            >
185                                { page_to_string(*page) }
186                            </button>
187                        </li>
188                    }
189                }) }
190
191                if show_first_last {
192                    <li class="pagination-item">
193                        <button
194                            type="button"
195                            disabled={current_page == total_pages}
196                            onclick={on_page_change.clone().map(move |cb| {
197                                let cb = cb.clone();
198                                move |_: MouseEvent| cb.emit(total_pages)
199                            })}
200                        >
201                            { total_pages.to_string() }
202                        </button>
203                    </li>
204                }
205
206                if show_prev_next {
207                    <li class="pagination-item">
208                        <button
209                            type="button"
210                            disabled={current_page >= total_pages}
211                            onclick={on_page_change.clone().map(move |cb| {
212                                let cb = cb.clone();
213                                move |_: MouseEvent| cb.emit(current_page + 1)
214                            })}
215                        >
216                            {"›"}
217                        </button>
218                    </li>
219                }
220            </ul>
221        </nav>
222    }
223}
224
225fn page_to_string(page: u32) -> String {
226    if page == 0 {
227        "...".to_string()
228    } else {
229        page.to_string()
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn page_to_string_normal() {
239        assert_eq!(page_to_string(1), "1");
240        assert_eq!(page_to_string(42), "42");
241    }
242
243    #[test]
244    fn page_to_string_ellipsis() {
245        assert_eq!(page_to_string(0), "...");
246    }
247
248    #[test]
249    fn pagination_props_default() {
250        let props = PaginationProps::default();
251        assert_eq!(props.current_page, 1);
252        assert_eq!(props.total_pages, 10);
253        assert_eq!(props.siblings, 1);
254        assert!(!props.show_first_last);
255        assert!(props.show_prev_next);
256        assert!(props.on_page_change.is_none());
257    }
258
259    #[test]
260    fn pagination_props_custom() {
261        let props = PaginationProps {
262            current_page:    5,
263            total_pages:     20,
264            siblings:        2,
265            show_first_last: true,
266            show_prev_next:  false,
267            on_page_change:  Some(Callback::from(|_: u32| {})),
268            classes:         Classes::from("my-pagination")
269        };
270        assert_eq!(props.current_page, 5);
271        assert_eq!(props.total_pages, 20);
272        assert!(props.show_first_last);
273        assert!(!props.show_prev_next);
274    }
275
276    #[test]
277    fn pagination_props_clone() {
278        let props = PaginationProps::default();
279        let cloned = props.clone();
280        assert_eq!(props, cloned);
281    }
282
283    #[test]
284    fn pagination_props_neq() {
285        let p1 = PaginationProps {
286            current_page: 1,
287            ..Default::default()
288        };
289        let p2 = PaginationProps {
290            current_page: 2,
291            ..Default::default()
292        };
293        assert_ne!(p1, p2);
294    }
295
296    #[test]
297    fn pagination_props_callback_invoke() {
298        use std::sync::{
299            Arc,
300            atomic::{AtomicU32, Ordering}
301        };
302
303        let counter = Arc::new(AtomicU32::new(0));
304        let counter_clone = counter.clone();
305        let cb = Callback::from(move |v: u32| {
306            counter_clone.store(v, Ordering::SeqCst);
307        });
308        let props = PaginationProps {
309            on_page_change: Some(cb),
310            ..Default::default()
311        };
312        props.on_page_change.as_ref().unwrap().emit(5);
313        assert_eq!(counter.load(Ordering::SeqCst), 5);
314    }
315}