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}