patternfly_yew/components/pagination/
mod.rs1mod simple;
4pub use simple::*;
5
6use crate::prelude::{
7 use_on_enter, AsClasses, Button, ButtonVariant, ExtendClasses, Icon, TextInput, TextInputType,
8};
9use yew::prelude::*;
10use yew_hooks::use_click_away;
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum PaginationPosition {
14 Top,
15 Bottom,
16}
17
18impl AsClasses for PaginationPosition {
19 fn extend_classes(&self, classes: &mut Classes) {
20 match self {
21 Self::Top => {}
22 Self::Bottom => classes.push(classes!("pf-m-top")),
23 }
24 }
25}
26
27impl PaginationPosition {
28 fn toggle_icon(&self, expanded: bool) -> Icon {
29 match (self, expanded) {
30 (Self::Bottom, true) => Icon::CaretUp,
31 _ => Icon::CaretDown,
32 }
33 }
34}
35
36#[derive(Clone, PartialEq, Properties)]
38pub struct PaginationProperties {
39 #[prop_or_default]
40 pub total_entries: Option<usize>,
41 #[prop_or_default]
42 pub offset: usize,
43 #[prop_or(vec![10,25,50])]
44 pub entries_per_page_choices: Vec<usize>,
45 #[prop_or(25)]
46 pub selected_choice: usize,
47
48 #[prop_or_default]
50 pub onnavigation: Callback<Navigation>,
51
52 #[prop_or_default]
54 pub onlimit: Callback<usize>,
55
56 #[prop_or_default]
58 pub id: Option<AttrValue>,
59
60 #[prop_or_default]
62 pub style: AttrValue,
63
64 #[prop_or(PaginationPosition::Top)]
65 pub position: PaginationPosition,
66
67 #[prop_or_default]
69 pub disabled: bool,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum Navigation {
74 First,
75 Previous,
76 Next,
77 Last,
78 Page(usize),
80}
81
82#[function_component(Pagination)]
96pub fn pagination(props: &PaginationProperties) -> Html {
97 let expanded = use_state_eq(|| false);
98
99 let mut menu_classes = classes!("pf-v5-c-options-menu");
101 menu_classes.extend_from(&props.position);
102
103 if *expanded {
104 menu_classes.push("pf-m-expanded");
105 }
106
107 let empty = props
109 .total_entries
110 .map(|total| total == 0)
111 .unwrap_or_default();
112
113 let max_page = props
115 .total_entries
116 .map(|m| (m as f64 / props.selected_choice as f64).ceil() as usize);
117
118 let current_page = match empty {
120 true => 0,
121 false => (props.offset as f64 / props.selected_choice as f64).ceil() as usize,
122 };
123
124 let is_last_page = if let Some(max) = props.total_entries {
126 props.offset + props.selected_choice >= max
127 } else {
128 false
129 };
130
131 let total_entries = props
133 .total_entries
134 .map(|m| format!("{}", m))
135 .unwrap_or_else(|| String::from("many"));
136
137 let start = match empty {
139 true => 0,
140 false => props.offset + 1,
142 };
143
144 let mut end = props.offset + props.selected_choice;
145 if let Some(total) = props.total_entries {
146 end = end.min(total);
147 }
148 let showing = format!("{start} - {end}",);
149
150 let limit_choices = props.entries_per_page_choices.clone();
151
152 let ontoggle = use_callback(expanded.clone(), |_, expanded| {
154 expanded.set(!**expanded);
155 });
156
157 let node = use_node_ref();
158 {
159 let expanded = expanded.clone();
160 use_click_away(node.clone(), move |_| {
161 expanded.set(false);
162 });
163 }
164
165 let input = use_state_eq(|| 0);
169 let input_text = use_state_eq(|| Some((current_page + 1).to_string()));
171
172 if input_text.is_none() {
173 input_text.set(Some((current_page + 1).to_string()));
174 }
175
176 let onkeydown = use_on_enter(
177 (input.clone(), props.onnavigation.clone(), max_page),
178 |(input, onnavigation, max_page)| {
179 let mut page: usize = **input;
180 if let Some(max_page) = max_page {
181 if page > *max_page {
182 page = *max_page;
183 }
184 }
185 page = page.saturating_sub(1);
187 log::debug!("Emit page change: {page}");
188 onnavigation.emit(Navigation::Page(page));
189 },
190 );
191
192 let onchange = use_callback(
193 (input.clone(), input_text.clone(), max_page, current_page),
194 |text: String, (input, input_text, max_page, current_page)| {
195 input_text.set(Some(text.clone()));
196
197 let value = match text.parse::<usize>() {
198 Ok(value) => {
199 let max_page = max_page.unwrap_or(usize::MAX);
200 if value > 0 && value <= max_page {
201 Some(value)
202 } else {
203 None
204 }
205 }
206 Err(_) => None,
207 };
208
209 if let Some(value) = value {
210 input.set(value);
211 } else {
212 input.set(current_page.saturating_add(1));
214 }
215
216 log::debug!("New prepared page value: {:?} / {}", **input_text, **input);
217 },
218 );
219
220 let onblur = use_callback(input_text.clone(), |_, input_text| {
221 input_text.set(None);
222 });
223
224 let onnavigation = use_callback(
225 (props.onnavigation.clone(), input_text.clone()),
226 |nav, (onnavigation, input_text)| {
227 input_text.set(None);
228 onnavigation.emit(nav);
229 },
230 );
231
232 {
234 let input_text = input_text.clone();
235 use_effect_with(
236 (props.offset, props.selected_choice, props.total_entries),
237 move |(offset, selected, total)| {
238 let r = (*offset as f64 / *selected as f64).ceil() as usize;
239
240 if *total == Some(0) {
241 input_text.set(Some("0".to_string()));
242 } else {
243 input_text.set(Some((r + 1).to_string()));
244 }
245 },
246 );
247 }
248
249 let onlimit = use_callback(
251 (props.onlimit.clone(), input_text.clone()),
252 |limit, (onlimit, input_text)| {
253 input_text.set(None);
254 onlimit.emit(limit);
255 },
256 );
257
258 let pagination_classes = match &props.position {
260 PaginationPosition::Top => classes!("pf-v5-c-pagination"),
261 PaginationPosition::Bottom => classes!("pf-v5-c-pagination", "pf-m-bottom"),
262 };
263
264 let pagination_styles = format!(
265 "--pf-v5-c-pagination__nav-page-select--c-form-control--width-chars: {};",
266 max_page.unwrap_or_default().to_string().len().clamp(2, 10)
267 );
268
269 let unbound = props.total_entries.is_none();
272
273 html! (
274
275 <div
276 id={&props.id}
277 class={pagination_classes}
278 style={[pagination_styles, props.style.to_string()].join(" ")}
279 ref={node}
280 >
281
282 <div class="pf-v5-c-pagination__total-items">
284 <b>{ showing.clone() }</b> {"\u{00a0}of\u{00a0}"}
285 <b>{ total_entries.clone() }</b>
286 </div>
287
288 <div class={ menu_classes }>
289 <button
290 class="pf-v5-c-options-menu__toggle pf-m-text pf-m-plain"
291 type="button"
292 aria-haspopup="listbox"
293 aria-expanded="true"
294 onclick={ontoggle}
295 disabled={props.disabled}
296 >
297 <span class="pf-v5-c-options-menu__toggle-text">
298 <b>{ showing }</b>{"\u{00a0}of\u{00a0}"}
299 <b>{ total_entries }</b>
300 </span>
301 <div class="pf-v5-c-options-menu__toggle-icon">
302 { props.position.toggle_icon(*expanded)}
303 </div>
304 </button>
305
306 if *expanded {
307 <ul class="pf-v5-c-options-menu__menu" >
308 { for limit_choices.into_iter().map(|limit| {
309 let expanded = expanded.clone();
310 let onlimit = onlimit.clone();
311 let onclick = Callback::from(move |_|{
312 onlimit.emit(limit);
313 expanded.set(false);
314 });
315 html!(
316 <li>
317 <button
318 class="pf-v5-c-options-menu__menu-item"
319 type="button"
320 {onclick}
321 >
322 {limit} {" per page"}
323 if props.selected_choice == limit {
324 <div class="pf-v5-c-options-menu__menu-item-icon">
325 { Icon::Check }
326 </div>
327 }
328 </button>
329 </li>
330 )})}
331 </ul>
332 }
333 </div>
334
335 <nav class="pf-v5-c-pagination__nav" aria-label="Pagination">
337 <div class="pf-v5-c-pagination__nav-control pf-m-first">
338 <Button
339 variant={ButtonVariant::Plain}
340 onclick={onnavigation.reform(|_|Navigation::First)}
341 disabled={ props.disabled || props.offset == 0 }
342 aria_label="Go to first page"
343 >
344 { Icon::AngleDoubleLeft }
345 </Button>
346 </div>
347 <div class="pf-v5-c-pagination__nav-control pf-m-prev">
348 <Button
349 aria_label="Go to previous page"
350 variant={ButtonVariant::Plain}
351 onclick={onnavigation.reform(|_|Navigation::Previous)}
352 disabled={ props.disabled || props.offset == 0 }
353 >
354 { Icon::AngleLeft }
355 </Button>
356 </div>
357 <div class="pf-v5-c-pagination__nav-page-select">
358 <TextInput
359 r#type={TextInputType::Number}
360 inputmode="number"
361 {onchange}
362 {onkeydown}
363 {onblur}
364 value={(*input_text).clone().unwrap_or_else(|| (current_page+1).to_string()) }
365 disabled={ props.disabled || empty }
366 />
367 if let Some(max_page) = max_page {
368 <span aria-hidden="true">{ "of "} { max_page }</span>
369 }
370 </div>
371
372 <div class="pf-v5-c-pagination__nav-control pf-m-next">
373 <Button
374 aria_label="Go to next page"
375 variant={ButtonVariant::Plain}
376 onclick={onnavigation.reform(|_|Navigation::Next)}
377 disabled={ props.disabled || is_last_page }
378 >
379 { Icon::AngleRight }
380 </Button>
381 </div>
382 <div class="pf-v5-c-pagination__nav-control pf-m-last">
383 <Button
384 aria_label="Go to last page"
385 variant={ButtonVariant::Plain}
386 onclick={onnavigation.reform(|_|Navigation::Last)}
387 disabled={ props.disabled || unbound || is_last_page }
388 >
389 { Icon::AngleDoubleRight }
390 </Button>
391 </div>
392 </nav>
393 </div>
394 )
395}