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}