Skip to main content

yew_nav_link/components/
pagination.rs

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